功能:大屏 APK 原生播报及项目入口
具体项目页由 APK 原生轮询并播报三次,总览页不播报。总览项目卡片通过项目编码进入对应大屏。
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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="暂无可监控项目" />}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user