实现叫号播报并调整现场业务规则

需求描述:发布屏在员工叫号后播放语音;同场次手机号被叫号后可重新取号;登录连续失败限制调整为10次。

实现思路:发布屏按新批次去重触发中文语音;重复手机号仅拦截WAITING号码;登录限流使用10次阈值,并补充前后端回归测试与接口说明。
This commit is contained in:
2026-07-31 16:36:55 +08:00
parent d782227a23
commit 78df07e074
7 changed files with 155 additions and 12 deletions

View File

@@ -1,10 +1,8 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-router-dom", () => ({ useParams: () => ({ token: "display-token" }) }));
vi.mock("../api", () => ({ api: { display: vi.fn() } }));
vi.mock("../hooks/usePollingResource", () => ({
usePollingResource: () => ({
const { pollingResource } = vi.hoisted(() => ({
pollingResource: {
data: {
project_name: "示范项目",
status: "RUNNING",
@@ -12,6 +10,7 @@ vi.mock("../hooks/usePollingResource", () => ({
waiting_ticket_count: 0,
waiting_people_count: 0,
experienced_people: 12,
current_batch: null,
recent_batches: [
{
batch_number: 2,
@@ -37,13 +36,26 @@ vi.mock("../hooks/usePollingResource", () => ({
offline: false,
lastClientSuccessAt: new Date().toISOString(),
refresh: vi.fn(),
}),
} as any,
}));
vi.mock("react-router-dom", () => ({ useParams: () => ({ token: "display-token" }) }));
vi.mock("../api", () => ({ api: { display: vi.fn() } }));
vi.mock("../hooks/usePollingResource", () => ({
usePollingResource: () => pollingResource,
}));
import { DisplayPage, forecastRows } from "./DisplayPage";
describe("DisplayPage", () => {
afterEach(() => vi.restoreAllMocks());
beforeEach(() => {
pollingResource.data.current_batch = null;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("renders public batches without requiring private batch IDs", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -76,4 +88,41 @@ describe("DisplayPage", () => {
{ range: "0007100075", wait: "6090 分钟" },
]);
});
it("新批次出现时只播报一次叫号内容", () => {
const speak = vi.fn();
const cancel = vi.fn();
class SpeechSynthesisUtteranceMock {
text: string;
lang = "";
rate = 1;
constructor(text: string) {
this.text = text;
}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel });
const { rerender } = render(<DisplayPage />);
pollingResource.data.current_batch = {
batch_number: 3,
status: "CALLED",
called_at: "2026-07-10T09:05:00Z",
tickets: [
{ ticket_number: "00005", status: "CALLED", party_size: 2 },
{ ticket_number: "00006", status: "CALLED", party_size: 1 },
],
};
rerender(<DisplayPage />);
rerender(<DisplayPage />);
expect(cancel).toHaveBeenCalledOnce();
expect(speak).toHaveBeenCalledOnce();
expect(speak.mock.calls[0][0]).toMatchObject({
text: "请零零零零五号至零零零零六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
});
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
import { usePollingResource } from "../hooks/usePollingResource";
@@ -20,6 +20,32 @@ 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);
@@ -51,6 +77,29 @@ export function DisplayPage() {
const freshnessTime = resource.lastClientSuccessAt;
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 hasSnapshot = Boolean(data);
const lastCallRef = useRef<{ token: string; key: string | null } | null>(null);
useEffect(() => {
if (!hasSnapshot) return;
const previous = lastCallRef.current;
lastCallRef.current = { token, key: currentCallKey };
if (
!previous
|| previous.token !== token
|| previous.key === currentCallKey
|| !currentAnnouncement
|| !("speechSynthesis" in window)
|| typeof SpeechSynthesisUtterance === "undefined"
) return;
const utterance = new SpeechSynthesisUtterance(currentAnnouncement);
utterance.lang = "zh-CN";
utterance.rate = 0.9;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}, [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) : [];