diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index ec0a269..e671ce2 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,6 +1,18 @@ import type { CallBatchDto, ProjectStatus, TicketStatus } from "../types"; const numberFormatter = new Intl.NumberFormat("zh-CN"); +const SPOKEN_DIGITS: Record = { + "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 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(); diff --git a/web/src/pages/DisplayPage.tsx b/web/src/pages/DisplayPage.tsx index 5b88945..06b8f63 100644 --- a/web/src/pages/DisplayPage.tsx +++ b/web/src/pages/DisplayPage.tsx @@ -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 = { - "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(() => { diff --git a/web/src/pages/PublicDisplayCenterPage.test.tsx b/web/src/pages/PublicDisplayCenterPage.test.tsx index 9aecc31..cdf9e8e 100644 --- a/web/src/pages/PublicDisplayCenterPage.test.tsx +++ b/web/src/pages/PublicDisplayCenterPage.test.tsx @@ -1,42 +1,57 @@ 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() })); +const { displayOverview, pollingResource } = vi.hoisted(() => ({ + displayOverview: vi.fn(), + pollingResource: { + data: { + projects: [{ + id: "project-1", + name: "东门观光车", + status: "RUNNING", + waiting_count: 1, + waiting_ticket_count: 1, + waiting_people_count: 3, + issued_ticket_count: 15, + latest_ticket_number: "00015", + experienced_people: 12, + current_batch: null, + estimated_wait: { available: true, lower_minutes: 5, upper_minutes: 15 }, + }], + server_time: "2026-07-31T08:00:00Z", + }, + loading: false, + refreshing: false, + error: null, + 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 { - data: { - projects: [{ - id: "project-1", - name: "东门观光车", - status: "RUNNING", - waiting_count: 1, - waiting_ticket_count: 1, - waiting_people_count: 3, - issued_ticket_count: 15, - latest_ticket_number: "00015", - experienced_people: 12, - current_batch: null, - estimated_wait: { available: true, lower_minutes: 5, upper_minutes: 15 }, - }], - server_time: "2026-07-31T08:00:00Z", - }, - loading: false, - refreshing: false, - error: null, - offline: false, - lastClientSuccessAt: new Date().toISOString(), - refresh: vi.fn(), - }; + 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(); @@ -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(); + + 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(); + pollingResource.data = { + ...pollingResource.data, + projects: pollingResource.data.projects.map((project: Record) => ({ ...project })), + }; + rerender(); + + expect(speak).toHaveBeenCalledOnce(); + expect(speak.mock.calls[0][0]).toMatchObject({ + text: "东门观光车,请零零零一六号,前往入口。", + lang: "zh-CN", + }); + }); }); diff --git a/web/src/pages/PublicDisplayCenterPage.tsx b/web/src/pages/PublicDisplayCenterPage.tsx index 6439f85..fbe2006 100644 --- a/web/src/pages/PublicDisplayCenterPage.tsx +++ b/web/src/pages/PublicDisplayCenterPage.tsx @@ -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 | null>(null); + useEffect(() => { + if (!data) return; + const previousCalls = lastCallsRef.current; + const nextCalls = new Map(); + 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 (