78 lines
3.6 KiB
TypeScript
78 lines
3.6 KiB
TypeScript
import { useEffect, useRef } from "react";
|
||
import { api } from "../api";
|
||
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
|
||
import { usePollingResource } from "../hooks/usePollingResource";
|
||
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
|
||
import type { CallBatchDto } from "../types";
|
||
import { DisplayCenter } from "./AdminPage";
|
||
|
||
function projectCurrentBatch(value: unknown): CallBatchDto | null {
|
||
return value && typeof value === "object" ? value as CallBatchDto : null;
|
||
}
|
||
|
||
export function PublicDisplayCenterPage() {
|
||
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(`${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]);
|
||
|
||
return (
|
||
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
||
<a className="skip-link" href="#main-content">跳到主要内容</a>
|
||
<header className="app-header">
|
||
<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-lockup__text"><strong>景区排队叫号系统</strong></span>
|
||
</div>
|
||
<div className="account-menu"><span><strong>大屏中心</strong></span></div>
|
||
</header>
|
||
<main id="main-content" className="app-main">
|
||
<section className="admin-content" aria-label="公开大屏内容">
|
||
{resource.loading && !data ? <LoadingState label="正在汇总项目状态" /> : null}
|
||
{resource.error && !data ? (
|
||
<FeedbackBanner tone="danger" title="大屏数据暂时不可用" action={<button className="button button--secondary button--small" onClick={resource.refresh}>重试</button>}>
|
||
{resource.error.message}
|
||
</FeedbackBanner>
|
||
) : null}
|
||
{data ? (
|
||
<>
|
||
<FreshnessBanner offline={resource.offline} stale={stale} timestamp={resource.lastClientSuccessAt} refreshing={resource.refreshing} errorMessage={resource.error?.message} onRetry={resource.refresh} />
|
||
<DisplayCenter projects={data.projects} />
|
||
</>
|
||
) : null}
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|