3 Commits

Author SHA1 Message Date
4405bf96f7 修复:将叫号音频内联到前端包
问题:音频使用 /audio 路径,浏览器预加载仍会向服务端发起独立请求。

实现:将 15 个 WAV 作为 Vite 内联资源编入前端 JS,后端继续仅返回叫号数据/文本。

验证:前端 66 项测试与生产构建通过;dist 中 WAV 文件为 0,含 15 个内联音频数据。
2026-08-01 12:16:54 +08:00
6d07612bc9 优化:公屏预加载叫号音频素材
需求:页面打开后缓存音频片段,叫号时避免临时下载造成延迟。

实现:页面挂载时预加载全部数字与提示语 WAV,后端继续返回叫号数据/文本,前端按文本映射片段播放。

验证:前端 66 项测试通过,生产构建通过。
2026-08-01 12:06:01 +08:00
01d261e893 优化:公屏叫号默认播放并移除声音控制
需求:服务端下发音频已验证可用,公屏无需再显示开启声音按钮和状态提示。

实现:移除声音控制组件与首次点击门槛,新叫号到达后直接播放内置音频队列。

验证:前端 65 项测试通过,生产构建通过。
2026-08-01 11:40:58 +08:00
23 changed files with 71 additions and 132 deletions

View File

@@ -1,37 +0,0 @@
import type { RefObject } from "react";
import type { AudioAnnouncementStatus } from "../hooks/useAudioAnnouncements";
interface AudioControlProps {
audioRef: RefObject<HTMLAudioElement | null>;
status: AudioAnnouncementStatus;
onEnable: () => void;
}
export function AudioControl({ audioRef, status, onEnable }: AudioControlProps) {
return (
<>
<audio ref={audioRef} preload="auto" aria-hidden="true" />
{status === "ready" ? (
<aside className="speech-control speech-control--ready" role="status">
<strong></strong>
<span></span>
</aside>
) : (
<aside className={`speech-control${status === "error" ? " speech-control--error" : ""}`} role="status">
<div>
<strong>{status === "error" ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
<span>{status === "error" ? "请检查设备媒体音量后重试" : "请点击一次,之后将自动播报"}</span>
</div>
<button
className="button button--primary"
type="button"
disabled={status === "enabling"}
onClick={onEnable}
>
{status === "enabling" ? "正在开启叫号声音" : status === "error" ? "重新开启叫号声音" : "开启叫号声音"}
</button>
</aside>
)}
</>
);
}

View File

@@ -1,37 +1,32 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { announcementAudioQueue } from "./useAudioAnnouncements"; import { announcementAudioQueue } from "./useAudioAnnouncements";
const inlineAudio = (source: string) => source.startsWith("data:audio/wav;base64,");
describe("announcementAudioQueue", () => { describe("announcementAudioQueue", () => {
it("忽略项目名并把单号码播报素材重复三遍", () => { it("忽略项目名并把单号码播报素材重复三遍", () => {
const once = [ const queue = announcementAudioQueue(["东门观光车,请零零零一六号,前往入口。"]);
"/audio/queue-voice/please.wav", const once = queue.slice(0, 9);
"/audio/queue-voice/digit-0.wav",
"/audio/queue-voice/digit-0.wav",
"/audio/queue-voice/digit-0.wav",
"/audio/queue-voice/digit-1.wav",
"/audio/queue-voice/digit-6.wav",
"/audio/queue-voice/number.wav",
"/audio/queue-voice/entrance.wav",
"/audio/queue-voice/gap.wav",
];
expect(announcementAudioQueue(["东门观光车,请零零零一六号,前往入口。"])) expect(once).toHaveLength(9);
.toEqual([...once, ...once, ...once]); expect(once.every(inlineAudio)).toBe(true);
expect(once[1]).toBe(once[2]);
expect(once[2]).toBe(once[3]);
expect(queue).toEqual([...once, ...once, ...once]);
}); });
it("支持连续号码范围", () => { it("支持连续号码范围", () => {
const queue = announcementAudioQueue(["请一七号至一九号,前往入口。"]); const queue = announcementAudioQueue(["请一七号至一九号,前往入口。"]);
expect(queue.slice(0, 9)).toEqual([ expect(queue.slice(0, 9).every(inlineAudio)).toBe(true);
"/audio/queue-voice/please.wav", expect(queue[1]).not.toBe(queue[2]);
"/audio/queue-voice/digit-1.wav", expect(queue[3]).not.toBe(queue[4]);
"/audio/queue-voice/digit-7.wav", });
"/audio/queue-voice/number.wav",
"/audio/queue-voice/through.wav", it("音频素材不使用独立 HTTP 路径", () => {
"/audio/queue-voice/digit-1.wav", const queue = announcementAudioQueue(["请一号,前往入口。"]);
"/audio/queue-voice/digit-9.wav",
"/audio/queue-voice/number.wav", expect(queue).not.toContain("/audio/queue-voice/please.wav");
"/audio/queue-voice/entrance.wav", expect(queue.every(inlineAudio)).toBe(true);
]);
}); });
}); });

View File

@@ -1,29 +1,41 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef } from "react";
import digit0Audio from "../assets/queue-voice/digit-0.wav?inline";
import digit1Audio from "../assets/queue-voice/digit-1.wav?inline";
import digit2Audio from "../assets/queue-voice/digit-2.wav?inline";
import digit3Audio from "../assets/queue-voice/digit-3.wav?inline";
import digit4Audio from "../assets/queue-voice/digit-4.wav?inline";
import digit5Audio from "../assets/queue-voice/digit-5.wav?inline";
import digit6Audio from "../assets/queue-voice/digit-6.wav?inline";
import digit7Audio from "../assets/queue-voice/digit-7.wav?inline";
import digit8Audio from "../assets/queue-voice/digit-8.wav?inline";
import digit9Audio from "../assets/queue-voice/digit-9.wav?inline";
import entranceAudio from "../assets/queue-voice/entrance.wav?inline";
import gapAudio from "../assets/queue-voice/gap.wav?inline";
import numberAudio from "../assets/queue-voice/number.wav?inline";
import pleaseAudio from "../assets/queue-voice/please.wav?inline";
import throughAudio from "../assets/queue-voice/through.wav?inline";
export type AudioAnnouncementStatus = "locked" | "enabling" | "ready" | "error";
const AUDIO_BASE_PATH = "/audio/queue-voice";
const ANNOUNCEMENT_REPEAT_COUNT = 3; const ANNOUNCEMENT_REPEAT_COUNT = 3;
const GAP_AUDIO = `${AUDIO_BASE_PATH}/gap.wav`; const GAP_AUDIO = gapAudio;
const DIGIT_AUDIO: Record<string, string> = { const DIGIT_AUDIO: Record<string, string> = {
: `${AUDIO_BASE_PATH}/digit-0.wav`, : digit0Audio,
: `${AUDIO_BASE_PATH}/digit-1.wav`, : digit1Audio,
: `${AUDIO_BASE_PATH}/digit-2.wav`, : digit2Audio,
: `${AUDIO_BASE_PATH}/digit-3.wav`, : digit3Audio,
: `${AUDIO_BASE_PATH}/digit-4.wav`, : digit4Audio,
: `${AUDIO_BASE_PATH}/digit-5.wav`, : digit5Audio,
: `${AUDIO_BASE_PATH}/digit-6.wav`, : digit6Audio,
: `${AUDIO_BASE_PATH}/digit-7.wav`, : digit7Audio,
: `${AUDIO_BASE_PATH}/digit-8.wav`, : digit8Audio,
: `${AUDIO_BASE_PATH}/digit-9.wav`, : digit9Audio,
}; };
const PHRASE_AUDIO = [ const PHRASE_AUDIO = [
["前往入口", `${AUDIO_BASE_PATH}/entrance.wav`], ["前往入口", entranceAudio],
["请", `${AUDIO_BASE_PATH}/please.wav`], ["请", pleaseAudio],
["号", `${AUDIO_BASE_PATH}/number.wav`], ["号", numberAudio],
["至", `${AUDIO_BASE_PATH}/through.wav`], ["至", throughAudio],
] as const; ] as const;
function clipsForAnnouncement(announcement: string): string[] { function clipsForAnnouncement(announcement: string): string[] {
@@ -62,14 +74,10 @@ export function announcementAudioQueue(announcements: string[]): string[] {
export function useAudioAnnouncements() { export function useAudioAnnouncements() {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const playbackIdRef = useRef(0); const playbackIdRef = useRef(0);
const [status, setStatus] = useState<AudioAnnouncementStatus>("locked");
const playQueue = useCallback((sources: string[], onStarted?: () => void) => { const playQueue = useCallback((sources: string[]) => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio || !sources.length) { if (!audio || !sources.length) return;
setStatus("error");
return;
}
const playbackId = ++playbackIdRef.current; const playbackId = ++playbackIdRef.current;
audio.pause(); audio.pause();
@@ -77,18 +85,10 @@ export function useAudioAnnouncements() {
audio.onerror = null; audio.onerror = null;
let cursor = 0; let cursor = 0;
let started = false; const fail = () => {
const markStarted = () => {
if (started || playbackIdRef.current !== playbackId) return;
started = true;
onStarted?.();
};
const fail = (error?: unknown) => {
if (playbackIdRef.current !== playbackId) return; if (playbackIdRef.current !== playbackId) return;
if (error instanceof DOMException && error.name === "AbortError") return;
audio.onended = null; audio.onended = null;
audio.onerror = null; audio.onerror = null;
setStatus("error");
}; };
const playNext = () => { const playNext = () => {
if (playbackIdRef.current !== playbackId) return; if (playbackIdRef.current !== playbackId) return;
@@ -107,28 +107,21 @@ export function useAudioAnnouncements() {
try { try {
const result = audio.play(); const result = audio.play();
if (result && typeof result.then === "function") { if (result && typeof result.then === "function") {
void result.then(markStarted).catch(fail); void result.catch(fail);
} else {
markStarted();
} }
} catch (error) { } catch {
fail(error); fail();
} }
}; };
playNext(); playNext();
}, []); }, []);
const enable = useCallback(() => {
setStatus("enabling");
playQueue([`${AUDIO_BASE_PATH}/ready.wav`], () => setStatus("ready"));
}, [playQueue]);
const announce = useCallback((announcements: string[]) => { const announce = useCallback((announcements: string[]) => {
if (status !== "ready" || !announcements.length) return; if (!announcements.length) return;
const queue = announcementAudioQueue(announcements); const queue = announcementAudioQueue(announcements);
if (queue.length) playQueue(queue); if (queue.length) playQueue(queue);
}, [playQueue, status]); }, [playQueue]);
useEffect(() => () => { useEffect(() => () => {
playbackIdRef.current += 1; playbackIdRef.current += 1;
@@ -139,5 +132,5 @@ export function useAudioAnnouncements() {
audio.onerror = null; audio.onerror = null;
}, []); }, []);
return { announce, audioRef, enable, status }; return { announce, audioRef };
} }

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pollingResource } = vi.hoisted(() => ({ const { pollingResource } = vi.hoisted(() => ({
@@ -95,7 +95,7 @@ describe("DisplayPage", () => {
]); ]);
}); });
it("新批次出现时用内置音频连续播报三次叫号内容", async () => { it("新批次出现时无需人工开启即可连续播报三次叫号内容", () => {
const playedSources: string[] = []; const playedSources: string[] = [];
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) { vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
@@ -104,13 +104,8 @@ describe("DisplayPage", () => {
}); });
const { container, rerender } = render(<DisplayPage />); const { container, rerender } = render(<DisplayPage />);
await act(async () => { expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" })); expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
await Promise.resolve();
});
expect(screen.getByText("叫号声音已开启")).toBeVisible();
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
playedSources.length = 0;
pollingResource.data.current_batch = { pollingResource.data.current_batch = {
batch_number: 3, batch_number: 3,

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { AudioControl } from "../components/AudioControl";
import { ProjectScreenTile } from "../components/ProjectScreenTile"; import { ProjectScreenTile } from "../components/ProjectScreenTile";
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements"; import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
import { usePollingResource } from "../hooks/usePollingResource"; import { usePollingResource } from "../hooks/usePollingResource";
@@ -12,7 +11,7 @@ export { forecastRows } from "../lib/display";
export function DisplayPage() { export function DisplayPage() {
const { token = "" } = useParams(); const { token = "" } = useParams();
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements(); const { announce, audioRef } = useAudioAnnouncements();
const resource = usePollingResource((signal) => api.display(token, signal), { const resource = usePollingResource((signal) => api.display(token, signal), {
enabled: Boolean(token), enabled: Boolean(token),
intervalMs: 3_000, intervalMs: 3_000,
@@ -70,7 +69,7 @@ export function DisplayPage() {
return ( return (
<main className="single-display-screen"> <main className="single-display-screen">
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} /> <audio ref={audioRef} preload="auto" aria-hidden="true" />
{(resource.offline || stale || resource.error) ? ( {(resource.offline || stale || resource.error) ? (
<div className="display-alert" role="status"> <div className="display-alert" role="status">
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong> <strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { displayOverview, pollingResource } = vi.hoisted(() => ({ const { displayOverview, pollingResource } = vi.hoisted(() => ({
@@ -63,7 +63,7 @@ describe("PublicDisplayCenterPage", () => {
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
}); });
it("新叫号批次出现时只用内置音频连续三次播报号码", async () => { it("新叫号批次出现时无需人工开启即可连续三次播报号码", () => {
const playedSources: string[] = []; const playedSources: string[] = [];
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) { vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
@@ -72,13 +72,8 @@ describe("PublicDisplayCenterPage", () => {
}); });
const { container, rerender } = render(<PublicDisplayCenterPage />); const { container, rerender } = render(<PublicDisplayCenterPage />);
await act(async () => { expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" })); expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
await Promise.resolve();
});
expect(screen.getByText("叫号声音已开启")).toBeVisible();
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
playedSources.length = 0;
pollingResource.data = { pollingResource.data = {
...pollingResource.data, ...pollingResource.data,

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { api } from "../api"; import { api } from "../api";
import { AudioControl } from "../components/AudioControl";
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback"; import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements"; import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
import { usePollingResource } from "../hooks/usePollingResource"; import { usePollingResource } from "../hooks/usePollingResource";
@@ -13,7 +12,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
} }
export function PublicDisplayCenterPage() { export function PublicDisplayCenterPage() {
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements(); const { announce, audioRef } = useAudioAnnouncements();
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 }); const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
const data = resource.data; const data = resource.data;
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000); const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
@@ -38,7 +37,7 @@ export function PublicDisplayCenterPage() {
return ( return (
<div className="app-shell app-shell--admin app-shell--no-primary-action"> <div className="app-shell app-shell--admin app-shell--no-primary-action">
<a className="skip-link" href="#main-content"></a> <a className="skip-link" href="#main-content"></a>
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} /> <audio ref={audioRef} preload="auto" aria-hidden="true" />
<header className="app-header"> <header className="app-header">
<div className="brand-lockup" aria-label="景区排队叫号系统"> <div className="brand-lockup" aria-label="景区排队叫号系统">
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span> <span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>