
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;