From d26d36c3d3b4ef59fc5951722c8ff201361932d0 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 31 Jul 2026 18:01:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=85=BC=E5=AE=B9Ch?= =?UTF-8?q?rome=E5=85=AC=E5=B1=8F=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:Chrome在页面没有用户操作时拒绝speechSynthesis并返回not-allowed,导致同机Safari有声但Chrome无声。 实现:两个公屏增加一次性声音启用操作,在用户手势中解锁语音;保留每次三遍播报,并补充暂停恢复、失败提示和回归测试。 复现:直接打开公屏页面后由员工端叫号,Chrome不播放声音。 --- web/src/components/SpeechControl.tsx | 38 +++++++++++ web/src/hooks/useSpeechAnnouncements.ts | 63 +++++++++++++++++++ web/src/pages/DisplayPage.test.tsx | 16 ++++- web/src/pages/DisplayPage.tsx | 16 ++--- .../pages/PublicDisplayCenterPage.test.tsx | 13 +++- web/src/pages/PublicDisplayCenterPage.tsx | 22 ++----- web/src/styles.css | 37 +++++++++++ 7 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 web/src/components/SpeechControl.tsx create mode 100644 web/src/hooks/useSpeechAnnouncements.ts diff --git a/web/src/components/SpeechControl.tsx b/web/src/components/SpeechControl.tsx new file mode 100644 index 0000000..874deba --- /dev/null +++ b/web/src/components/SpeechControl.tsx @@ -0,0 +1,38 @@ +import type { SpeechAnnouncementStatus } from "../hooks/useSpeechAnnouncements"; + +interface SpeechControlProps { + status: SpeechAnnouncementStatus; + onEnable: () => void; +} + +export function SpeechControl({ status, onEnable }: SpeechControlProps) { + if (status === "ready") { + return ( + + ); + } + + const unsupported = status === "unsupported"; + const failed = status === "error"; + return ( + + ); +} diff --git a/web/src/hooks/useSpeechAnnouncements.ts b/web/src/hooks/useSpeechAnnouncements.ts new file mode 100644 index 0000000..73fbff1 --- /dev/null +++ b/web/src/hooks/useSpeechAnnouncements.ts @@ -0,0 +1,63 @@ +import { useCallback, useState } from "react"; + +export type SpeechAnnouncementStatus = "locked" | "enabling" | "ready" | "error" | "unsupported"; + +const ANNOUNCEMENT_REPEAT_COUNT = 3; + +function speechSupported(): boolean { + return typeof window !== "undefined" + && "speechSynthesis" in window + && typeof SpeechSynthesisUtterance !== "undefined"; +} + +function createUtterance(text: string): SpeechSynthesisUtterance { + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = "zh-CN"; + utterance.rate = 0.9; + return utterance; +} + +export function useSpeechAnnouncements() { + const [status, setStatus] = useState(() => ( + speechSupported() ? "locked" : "unsupported" + )); + + const enable = useCallback(() => { + if (!speechSupported()) { + setStatus("unsupported"); + return; + } + + const synthesis = window.speechSynthesis; + const utterance = createUtterance("叫号声音已开启。"); + utterance.onstart = () => setStatus("ready"); + utterance.onend = () => setStatus("ready"); + utterance.onerror = (event) => { + if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error"); + }; + + setStatus("enabling"); + synthesis.cancel(); + if (synthesis.paused) synthesis.resume(); + synthesis.speak(utterance); + }, []); + + const announce = useCallback((announcements: string[]) => { + if (status !== "ready" || !announcements.length || !speechSupported()) return; + + const synthesis = window.speechSynthesis; + synthesis.cancel(); + if (synthesis.paused) synthesis.resume(); + for (const announcement of announcements) { + for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) { + const utterance = createUtterance(announcement); + utterance.onerror = (event) => { + if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error"); + }; + synthesis.speak(utterance); + } + } + }, [status]); + + return { announce, enable, status }; +} diff --git a/web/src/pages/DisplayPage.test.tsx b/web/src/pages/DisplayPage.test.tsx index 74dad2b..71de55c 100644 --- a/web/src/pages/DisplayPage.test.tsx +++ b/web/src/pages/DisplayPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { pollingResource } = vi.hoisted(() => ({ @@ -96,15 +96,27 @@ describe("DisplayPage", () => { text: string; lang = ""; rate = 1; + onstart: (() => void) | null = null; + onend: (() => void) | null = null; + onerror: ((event: { error: string }) => void) | null = null; constructor(text: string) { this.text = text; } } vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock); - vi.stubGlobal("speechSynthesis", { speak, cancel }); + vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() }); const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" })); + act(() => { + speak.mock.calls[0][0].onstart?.(); + speak.mock.calls[0][0].onerror?.({ error: "interrupted" }); + }); + expect(screen.getByText("叫号声音已开启")).toBeVisible(); + speak.mockClear(); + cancel.mockClear(); + pollingResource.data.current_batch = { batch_number: 3, status: "CALLED", diff --git a/web/src/pages/DisplayPage.tsx b/web/src/pages/DisplayPage.tsx index ef08299..14f78fe 100644 --- a/web/src/pages/DisplayPage.tsx +++ b/web/src/pages/DisplayPage.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { api } from "../api"; +import { SpeechControl } from "../components/SpeechControl"; import { usePollingResource } from "../hooks/usePollingResource"; +import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements"; import { formatCallAnnouncement, formatDateTime, formatTicketNumberRange, isTimestampStale, projectStatusMeta } from "../lib/format"; import type { CallBatchDto } from "../types"; @@ -42,6 +44,7 @@ export function forecastRows(batch: CallBatchDto | null | undefined, waitingCoun export function DisplayPage() { const { token = "" } = useParams(); + const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements(); const resource = usePollingResource((signal) => api.display(token, signal), { enabled: Boolean(token), intervalMs: 3_000, @@ -64,18 +67,10 @@ export function DisplayPage() { || previous.token !== token || previous.key === currentCallKey || !currentAnnouncement - || !("speechSynthesis" in window) - || typeof SpeechSynthesisUtterance === "undefined" ) return; - window.speechSynthesis.cancel(); - for (let repeat = 0; repeat < 3; repeat += 1) { - const utterance = new SpeechSynthesisUtterance(currentAnnouncement); - utterance.lang = "zh-CN"; - utterance.rate = 0.9; - window.speechSynthesis.speak(utterance); - } - }, [currentAnnouncement, currentCallKey, hasSnapshot, token]); + announce([currentAnnouncement]); + }, [announce, currentAnnouncement, currentCallKey, hasSnapshot, token]); const waitingTicketCount = data?.waiting_ticket_count ?? data?.waiting_count ?? 0; const waitingPeopleCount = data?.waiting_people_count ?? 0; const forecasts = data ? forecastRows(current, waitingTicketCount, data.estimated_wait) : []; @@ -106,6 +101,7 @@ export function DisplayPage() { return (
+
小七孔文旅集团 diff --git a/web/src/pages/PublicDisplayCenterPage.test.tsx b/web/src/pages/PublicDisplayCenterPage.test.tsx index 3796e93..5ab968a 100644 --- a/web/src/pages/PublicDisplayCenterPage.test.tsx +++ b/web/src/pages/PublicDisplayCenterPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { displayOverview, pollingResource } = vi.hoisted(() => ({ @@ -67,15 +67,24 @@ describe("PublicDisplayCenterPage", () => { class SpeechSynthesisUtteranceMock { text: string; lang = ""; + rate = 1; + onstart: (() => void) | null = null; + onend: (() => void) | null = null; + onerror: ((event: { error: string }) => void) | null = null; constructor(text: string) { this.text = text; } } vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock); - vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn() }); + vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn(), paused: false, resume: vi.fn() }); const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" })); + act(() => speak.mock.calls[0][0].onstart?.()); + expect(screen.getByText("叫号声音已开启")).toBeVisible(); + speak.mockClear(); + pollingResource.data = { ...pollingResource.data, projects: [{ diff --git a/web/src/pages/PublicDisplayCenterPage.tsx b/web/src/pages/PublicDisplayCenterPage.tsx index 2a5c193..9672213 100644 --- a/web/src/pages/PublicDisplayCenterPage.tsx +++ b/web/src/pages/PublicDisplayCenterPage.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef } from "react"; import { api } from "../api"; import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback"; +import { SpeechControl } from "../components/SpeechControl"; import { usePollingResource } from "../hooks/usePollingResource"; +import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements"; import { formatCallAnnouncement, isTimestampStale } from "../lib/format"; import type { CallBatchDto } from "../types"; import { DisplayCenter } from "./AdminPage"; @@ -11,6 +13,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null { } export function PublicDisplayCenterPage() { + const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements(); const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 }); const data = resource.data; const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000); @@ -29,26 +32,13 @@ export function PublicDisplayCenterPage() { 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) { - for (let repeat = 0; repeat < 3; repeat += 1) { - const utterance = new SpeechSynthesisUtterance(announcement); - utterance.lang = "zh-CN"; - utterance.rate = 0.9; - window.speechSynthesis.speak(utterance); - } - } - }, [data]); + announce(announcements); + }, [announce, data]); return (
跳到主要内容 +
diff --git a/web/src/styles.css b/web/src/styles.css index 8ac8eb3..9eba2dd 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2481,6 +2481,43 @@ tbody tr:hover { font-weight: 700; } +.speech-control { + position: fixed; + z-index: 60; + right: max(18px, env(safe-area-inset-right)); + bottom: max(18px, env(safe-area-inset-bottom)); + display: flex; + align-items: center; + gap: var(--space-4); + max-width: min(620px, calc(100vw - 36px)); + border: 1px solid #fbbf24; + border-radius: var(--radius-card); + padding: var(--space-3) var(--space-4); + background: rgba(20, 28, 38, .96); + box-shadow: 0 16px 40px rgba(0, 0, 0, .28); + color: #fff; +} + +.speech-control > div, +.speech-control--ready { + display: grid; + gap: 2px; +} + +.speech-control span { + color: #dbe5ee; + font-size: .88rem; +} + +.speech-control--ready { + border-color: #34d399; + background: rgba(5, 76, 52, .96); +} + +.speech-control--error { + border-color: #fb7185; +} + .display-header { grid-row: 1; display: flex;