功能:大屏 APK 原生播报及项目入口

具体项目页由 APK 原生轮询并播报三次,总览页不播报。总览项目卡片通过项目编码进入对应大屏。
This commit is contained in:
2026-08-11 00:58:17 +08:00
parent 5ad8bfcd03
commit e4e3e6a735
27 changed files with 728 additions and 236 deletions

View File

@@ -5,10 +5,11 @@ import type { AdminProjectDto, CallBatchDto } from "../types";
interface ProjectScreenTileProps {
project: AdminProjectDto;
standalone?: boolean;
displayHref?: string;
onFullscreen?: (projectId: string) => Promise<void>;
}
export function ProjectScreenTile({ project, standalone = false, onFullscreen }: ProjectScreenTileProps) {
export function ProjectScreenTile({ project, standalone = false, displayHref, onFullscreen }: ProjectScreenTileProps) {
const current = project.current_batch && typeof project.current_batch === "object"
? project.current_batch as CallBatchDto
: null;
@@ -50,8 +51,9 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }:
hour12: false,
}).format(now);
return (
<article className={`screen-tile${standalone ? " screen-tile--standalone" : ""}`} data-project-screen={project.id}>
const className = `screen-tile${standalone ? " screen-tile--standalone" : ""}${displayHref ? " screen-tile--link" : ""}`;
const content = (
<>
<header className="screen-tile__header">
<div className="screen-tile__clock" aria-label={`当前时间 ${dateLabel} ${timeLabel}`}>
<img className="screen-tile__logo" src="/xiaoqikong-logo.jpg" alt="" aria-hidden="true" />
@@ -63,7 +65,9 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }:
<div className="screen-tile__project">
<h2>{project.name}</h2>
</div>
{!standalone && onFullscreen ? (
{displayHref ? (
<span className="button button--primary screen-tile__fullscreen" aria-hidden="true"></span>
) : !standalone && onFullscreen ? (
<button className="button button--primary screen-tile__fullscreen" onClick={() => void onFullscreen(project.id)}></button>
) : null}
</header>
@@ -83,6 +87,21 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }:
<ol>{visible.map((item) => <li key={item.range}><strong>{item.range}</strong><span>{item.wait}</span></li>)}</ol>
) : <p></p>}
</section>
</article>
</>
);
if (displayHref) {
return (
<a
className={className}
href={displayHref}
aria-label={`进入${project.name}项目大屏`}
data-project-screen={project.id}
>
{content}
</a>
);
}
return <article className={className} data-project-screen={project.id}>{content}</article>;
}

View File

@@ -1,43 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSpeechAnnouncements } from "./useSpeechAnnouncements";
class SpeechSynthesisUtteranceMock {
lang = "";
rate = 1;
constructor(public text: string) {}
}
describe("useSpeechAnnouncements", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("把叫号文本用中文语音连续播报三次", () => {
const speak = vi.fn();
const cancel = vi.fn();
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { result } = renderHook(() => useSpeechAnnouncements());
act(() => result.current.announce(["请零零零一六号,前往入口。"]));
expect(cancel).toHaveBeenCalledOnce();
expect(speak).toHaveBeenCalledTimes(3);
for (const [utterance] of speak.mock.calls) {
expect(utterance).toMatchObject({
text: "请零零零一六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
}
});
it("浏览器不支持语音合成时安全跳过", () => {
const { result } = renderHook(() => useSpeechAnnouncements());
expect(() => result.current.announce(["请一号,前往入口。"])).not.toThrow();
});
});

View File

@@ -1,33 +0,0 @@
import { useCallback } from "react";
const ANNOUNCEMENT_REPEAT_COUNT = 3;
function speechSupported(): boolean {
return typeof window !== "undefined"
&& typeof window.speechSynthesis?.speak === "function"
&& 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 announce = useCallback((announcements: string[]) => {
if (!speechSupported() || !announcements.length) 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) {
synthesis.speak(createUtterance(announcement));
}
}
}, []);
return { announce };
}

View File

@@ -1,18 +1,6 @@
import type { CallBatchDto, ProjectStatus, TicketStatus } from "../types";
const numberFormatter = new Intl.NumberFormat("zh-CN");
const SPOKEN_DIGITS: Record<string, string> = {
"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);
@@ -119,16 +107,6 @@ export function formatTicketNumberRange(numbers: Array<string | null | undefined
return `${valid[0]}${valid[valid.length - 1]}`;
}
export function formatCallAnnouncement(batch?: CallBatchDto | null): string | null {
const numbers = batch?.tickets.map((ticket) => 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();

View File

@@ -344,7 +344,12 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
</div>;
}
export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
interface DisplayCenterProps {
projects: AdminProjectDto[];
projectHref?: (project: AdminProjectDto) => string | undefined;
}
export function DisplayCenter({ projects, projectHref }: DisplayCenterProps) {
const [fullscreenError, setFullscreenError] = useState(false);
const enterFullscreen = async (projectId: string) => {
const tile = document.querySelector(`[data-project-screen="${projectId}"]`);
@@ -365,7 +370,14 @@ export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
{fullscreenError ? <FeedbackBanner tone="warning" title="浏览器未允许全屏"></FeedbackBanner> : null}
{projects.length ? (
<section className="screen-wall" aria-label="项目实时监控墙">
{projects.map((project) => <ProjectScreenTile project={project} onFullscreen={enterFullscreen} key={project.id} />)}
{projects.map((project) => (
<ProjectScreenTile
project={project}
displayHref={projectHref?.(project)}
onFullscreen={projectHref ? undefined : enterFullscreen}
key={project.id}
/>
))}
</section>
) : <EmptyState title="暂无可监控项目" />}
</>

View File

@@ -94,43 +94,12 @@ describe("DisplayPage", () => {
]);
});
it("新批次出现时用前端中文语音连续播报三次叫号内容", () => {
const speak = vi.fn();
const cancel = vi.fn();
class SpeechSynthesisUtteranceMock {
lang = "";
rate = 1;
constructor(public text: string) {}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { container, rerender } = render(<DisplayPage />);
it("网页端不包含叫号声音控件或音频播放器", () => {
const { container } = render(<DisplayPage />);
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
expect(container.querySelector("audio")).toBeNull();
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).toHaveBeenCalledTimes(3);
for (const [utterance] of speak.mock.calls) {
expect(utterance).toMatchObject({
text: "请零零零零五号至零零零零六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
}
});
});

View File

@@ -1,17 +1,14 @@
import { useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
import { ProjectScreenTile } from "../components/ProjectScreenTile";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format";
import { formatDateTime, isTimestampStale } from "../lib/format";
import type { AdminProjectDto } from "../types";
export { forecastRows } from "../lib/display";
export function DisplayPage() {
const { token = "" } = useParams();
const { announce } = useSpeechAnnouncements();
const resource = usePollingResource((signal) => api.display(token, signal), {
enabled: Boolean(token),
intervalMs: 3_000,
@@ -20,24 +17,6 @@ export function DisplayPage() {
const data = resource.data;
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 = formatCallAnnouncement(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
) return;
announce([currentAnnouncement]);
}, [announce, currentAnnouncement, currentCallKey, hasSnapshot, token]);
if (resource.loading && !data) {
return <main className="display-page display-page--state"><strong></strong></main>;
}

View File

@@ -7,6 +7,7 @@ const { displayOverview, pollingResource } = vi.hoisted(() => ({
data: {
projects: [{
id: "project-1",
code: "YYHHC",
name: "东门观光车",
status: "RUNNING",
waiting_count: 1,
@@ -43,7 +44,7 @@ describe("PublicDisplayCenterPage", () => {
beforeEach(() => {
pollingResource.data = {
...pollingResource.data,
projects: [{ ...pollingResource.data.projects[0], current_batch: null }],
projects: [{ ...pollingResource.data.projects[0], code: "YYHHC", current_batch: null }],
};
});
@@ -58,54 +59,30 @@ describe("PublicDisplayCenterPage", () => {
expect(displayOverview).toHaveBeenCalledOnce();
expect(screen.getByRole("region", { name: "项目实时监控墙" })).toBeVisible();
expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible();
expect(screen.getByRole("link", { name: "进入东门观光车项目大屏" })).toHaveAttribute("href", "/display/YYHHC");
expect(screen.getByText("进入项目大屏")).toBeVisible();
expect(screen.queryByRole("button", { name: "退出" })).not.toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
});
it("新叫号批次出现时用前端中文语音连续三次播报号码", () => {
const speak = vi.fn();
const cancel = vi.fn();
class SpeechSynthesisUtteranceMock {
lang = "";
rate = 1;
constructor(public text: string) {}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { container, rerender } = render(<PublicDisplayCenterPage />);
it("总览页不包含叫号声音控件或音频播放器", () => {
const { container } = render(<PublicDisplayCenterPage />);
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
expect(container.querySelector("audio")).toBeNull();
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(<PublicDisplayCenterPage />);
pollingResource.data = {
...pollingResource.data,
projects: pollingResource.data.projects.map((project: Record<string, unknown>) => ({ ...project })),
};
rerender(<PublicDisplayCenterPage />);
});
expect(cancel).toHaveBeenCalledOnce();
expect(speak).toHaveBeenCalledTimes(3);
for (const [utterance] of speak.mock.calls) {
expect(utterance).toMatchObject({
text: "请零零零一六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
}
it("项目编码缺失时不会用内部项目 ID 生成大屏地址", () => {
pollingResource.data = {
...pollingResource.data,
projects: [{ ...pollingResource.data.projects[0], code: undefined }],
};
render(<PublicDisplayCenterPage />);
expect(screen.queryByRole("link", { name: "进入东门观光车项目大屏" })).not.toBeInTheDocument();
expect(screen.queryByText("进入项目大屏")).not.toBeInTheDocument();
});
});

View File

@@ -1,38 +1,19 @@
import { useEffect, useRef } from "react";
import { api } from "../api";
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
import type { CallBatchDto } from "../types";
import { isTimestampStale } from "../lib/format";
import type { AdminProjectDto } from "../types";
import { DisplayCenter } from "./AdminPage";
function projectCurrentBatch(value: unknown): CallBatchDto | null {
return value && typeof value === "object" ? value as CallBatchDto : null;
function projectDisplayHref(project: AdminProjectDto) {
const code = project.code?.trim();
return code ? `/display/${encodeURIComponent(code)}` : undefined;
}
export function PublicDisplayCenterPage() {
const { announce } = useSpeechAnnouncements();
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<Map<string, string | null> | null>(null);
useEffect(() => {
if (!data) return;
const previousCalls = lastCallsRef.current;
const nextCalls = new Map<string, string | null>();
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(announcement);
}
lastCallsRef.current = nextCalls;
announce(announcements);
}, [announce, data]);
return (
<div className="app-shell app-shell--admin app-shell--no-primary-action">
@@ -55,7 +36,7 @@ export function PublicDisplayCenterPage() {
{data ? (
<>
<FreshnessBanner offline={resource.offline} stale={stale} timestamp={resource.lastClientSuccessAt} refreshing={resource.refreshing} errorMessage={resource.error?.message} onRetry={resource.refresh} />
<DisplayCenter projects={data.projects} />
<DisplayCenter projects={data.projects} projectHref={projectDisplayHref} />
</>
) : null}
</section>

View File

@@ -7405,3 +7405,43 @@ body:has(.visitor-page--mobile) {
padding-block: var(--space-4);
}
}
/* Public overview cards are full-document links so the Android WebView can
detect the project URL and start native announcements. */
.screen-tile--link {
cursor: pointer;
color: inherit;
text-decoration: none;
transition:
border-color var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard),
transform var(--motion-fast) var(--ease-standard);
}
.screen-tile--link:hover {
border-color: rgba(11, 107, 58, .46);
box-shadow: 0 18px 42px rgba(16, 38, 29, .14);
text-decoration: none;
transform: translateY(-2px);
}
.screen-tile--link:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 4px;
text-decoration: none;
}
.screen-tile--link:active {
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.screen-tile--link {
transition: none;
}
.screen-tile--link:hover,
.screen-tile--link:active {
transform: none;
}
}