修复:补齐多项目公屏叫号播报
需求:管理员公屏在员工叫号后需播放项目名与号码。 实现:复用单项目号码播报格式,按项目跟踪新批次并播报项目名加号码,补充回归测试。
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
import type { CallBatchDto, ProjectStatus, TicketStatus } from "../types";
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("zh-CN");
|
||||
const SPOKEN_DIGITS: Record<string, string> = {
|
||||
"0": "零",
|
||||
"1": "一",
|
||||
"2": "二",
|
||||
"3": "三",
|
||||
"4": "四",
|
||||
"5": "五",
|
||||
"6": "六",
|
||||
"7": "七",
|
||||
"8": "八",
|
||||
"9": "九",
|
||||
};
|
||||
|
||||
export function formatNumber(value: unknown, fallback = "暂无"): string {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
@@ -107,6 +119,16 @@ export function formatTicketNumberRange(numbers: Array<string | null | undefined
|
||||
return `${valid[0]} 至 ${valid[valid.length - 1]}`;
|
||||
}
|
||||
|
||||
export function formatCallAnnouncement(batch?: CallBatchDto | null): string | null {
|
||||
const numbers = batch?.tickets.map((ticket) => ticket.ticket_number.trim()).filter(Boolean) ?? [];
|
||||
if (!numbers.length) return null;
|
||||
const spokenNumber = (number: string) => Array.from(number, (character) => SPOKEN_DIGITS[character] ?? character).join("");
|
||||
const calledNumbers = numbers.length === 1
|
||||
? `${spokenNumber(numbers[0])}号`
|
||||
: `${spokenNumber(numbers[0])}号至${spokenNumber(numbers[numbers.length - 1])}号`;
|
||||
return `请${calledNumbers},前往入口。`;
|
||||
}
|
||||
|
||||
export function isTimestampStale(value?: string | null, thresholdMs = 30_000, now = Date.now()): boolean {
|
||||
if (!value) return true;
|
||||
const timestamp = new Date(value).getTime();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { usePollingResource } from "../hooks/usePollingResource";
|
||||
import { formatDateTime, formatTicketNumberRange, isTimestampStale, projectStatusMeta } from "../lib/format";
|
||||
import { formatCallAnnouncement, formatDateTime, formatTicketNumberRange, isTimestampStale, projectStatusMeta } from "../lib/format";
|
||||
import type { CallBatchDto } from "../types";
|
||||
|
||||
function batchNumbers(batch?: CallBatchDto | null): string[] {
|
||||
@@ -20,32 +20,6 @@ function waitRange(value: unknown): { min: number; max: number } | null {
|
||||
|
||||
const FORECAST_INTERVAL_SIZE = 30;
|
||||
const FORECAST_PAGE_SIZE = 4;
|
||||
const SPOKEN_DIGITS: Record<string, string> = {
|
||||
"0": "零",
|
||||
"1": "一",
|
||||
"2": "二",
|
||||
"3": "三",
|
||||
"4": "四",
|
||||
"5": "五",
|
||||
"6": "六",
|
||||
"7": "七",
|
||||
"8": "八",
|
||||
"9": "九",
|
||||
};
|
||||
|
||||
function spokenTicketNumber(number: string): string {
|
||||
return Array.from(number.trim(), (character) => SPOKEN_DIGITS[character] ?? character).join("");
|
||||
}
|
||||
|
||||
function callAnnouncement(batch?: CallBatchDto | null): string | null {
|
||||
const numbers = batchNumbers(batch);
|
||||
if (!numbers.length) return null;
|
||||
const first = spokenTicketNumber(numbers[0]);
|
||||
const calledNumbers = numbers.length === 1
|
||||
? `${first}号`
|
||||
: `${first}号至${spokenTicketNumber(numbers[numbers.length - 1])}号`;
|
||||
return `请${calledNumbers},前往入口。`;
|
||||
}
|
||||
|
||||
export function forecastRows(batch: CallBatchDto | null | undefined, waitingCount: number, estimatedWait: unknown) {
|
||||
const called = batchNumbers(batch);
|
||||
@@ -78,7 +52,7 @@ export function DisplayPage() {
|
||||
const stale = Boolean(data) && isTimestampStale(freshnessTime, 15_000);
|
||||
const current = data?.current_batch;
|
||||
const currentCallKey = current ? `${current.batch_number}:${current.called_at ?? ""}` : null;
|
||||
const currentAnnouncement = callAnnouncement(current);
|
||||
const currentAnnouncement = formatCallAnnouncement(current);
|
||||
const hasSnapshot = Boolean(data);
|
||||
const lastCallRef = useRef<{ token: string; key: string | null } | null>(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { displayOverview } = vi.hoisted(() => ({ displayOverview: vi.fn() }));
|
||||
|
||||
vi.mock("../api", () => ({ api: { displayOverview } }));
|
||||
vi.mock("../hooks/usePollingResource", () => ({
|
||||
usePollingResource: (loader: (signal: AbortSignal) => unknown) => {
|
||||
loader(new AbortController().signal);
|
||||
return {
|
||||
const { displayOverview, pollingResource } = vi.hoisted(() => ({
|
||||
displayOverview: vi.fn(),
|
||||
pollingResource: {
|
||||
data: {
|
||||
projects: [{
|
||||
id: "project-1",
|
||||
@@ -30,13 +26,32 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
offline: false,
|
||||
lastClientSuccessAt: new Date().toISOString(),
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
} as any,
|
||||
}));
|
||||
|
||||
vi.mock("../api", () => ({ api: { displayOverview } }));
|
||||
vi.mock("../hooks/usePollingResource", () => ({
|
||||
usePollingResource: (loader: (signal: AbortSignal) => unknown) => {
|
||||
loader(new AbortController().signal);
|
||||
return pollingResource;
|
||||
},
|
||||
}));
|
||||
|
||||
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
|
||||
|
||||
describe("PublicDisplayCenterPage", () => {
|
||||
beforeEach(() => {
|
||||
pollingResource.data = {
|
||||
...pollingResource.data,
|
||||
projects: [{ ...pollingResource.data.projects[0], current_batch: null }],
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses public data and renders without administrator controls", () => {
|
||||
render(<PublicDisplayCenterPage />);
|
||||
|
||||
@@ -46,4 +61,44 @@ describe("PublicDisplayCenterPage", () => {
|
||||
expect(screen.queryByRole("button", { name: "退出" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("新叫号批次出现时播报项目和号码", () => {
|
||||
const speak = vi.fn();
|
||||
class SpeechSynthesisUtteranceMock {
|
||||
text: string;
|
||||
lang = "";
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
|
||||
vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn() });
|
||||
const { rerender } = render(<PublicDisplayCenterPage />);
|
||||
|
||||
pollingResource.data = {
|
||||
...pollingResource.data,
|
||||
projects: [{
|
||||
...pollingResource.data.projects[0],
|
||||
current_batch: {
|
||||
batch_number: 3,
|
||||
status: "CALLED",
|
||||
called_at: "2026-07-31T08:01:00Z",
|
||||
tickets: [{ ticket_number: "00016", status: "CALLED", party_size: 2 }],
|
||||
},
|
||||
}],
|
||||
};
|
||||
rerender(<PublicDisplayCenterPage />);
|
||||
pollingResource.data = {
|
||||
...pollingResource.data,
|
||||
projects: pollingResource.data.projects.map((project: Record<string, unknown>) => ({ ...project })),
|
||||
};
|
||||
rerender(<PublicDisplayCenterPage />);
|
||||
|
||||
expect(speak).toHaveBeenCalledOnce();
|
||||
expect(speak.mock.calls[0][0]).toMatchObject({
|
||||
text: "东门观光车,请零零零一六号,前往入口。",
|
||||
lang: "zh-CN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { api } from "../api";
|
||||
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
|
||||
import { usePollingResource } from "../hooks/usePollingResource";
|
||||
import { isTimestampStale } from "../lib/format";
|
||||
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
|
||||
import type { CallBatchDto } from "../types";
|
||||
import { DisplayCenter } from "./AdminPage";
|
||||
|
||||
function projectCurrentBatch(value: unknown): CallBatchDto | null {
|
||||
return value && typeof value === "object" ? value as CallBatchDto : null;
|
||||
}
|
||||
|
||||
export function PublicDisplayCenterPage() {
|
||||
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
|
||||
const data = resource.data;
|
||||
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
|
||||
const lastCallsRef = useRef<Map<string, string | null> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const previousCalls = lastCallsRef.current;
|
||||
const nextCalls = new Map<string, string | null>();
|
||||
const announcements: string[] = [];
|
||||
for (const project of data.projects) {
|
||||
const batch = projectCurrentBatch(project.current_batch);
|
||||
const key = batch ? `${batch.batch_number}:${batch.called_at ?? ""}` : null;
|
||||
nextCalls.set(project.id, key);
|
||||
if (!previousCalls?.has(project.id) || !key || previousCalls.get(project.id) === key) continue;
|
||||
const announcement = formatCallAnnouncement(batch);
|
||||
if (announcement) announcements.push(`${project.name},${announcement}`);
|
||||
}
|
||||
lastCallsRef.current = nextCalls;
|
||||
if (
|
||||
!announcements.length
|
||||
|| !("speechSynthesis" in window)
|
||||
|| typeof SpeechSynthesisUtterance === "undefined"
|
||||
) return;
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
for (const announcement of announcements) {
|
||||
const utterance = new SpeechSynthesisUtterance(announcement);
|
||||
utterance.lang = "zh-CN";
|
||||
utterance.rate = 0.9;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
||||
|
||||
Reference in New Issue
Block a user