统一单项目大屏与管理端全屏布局

需求:/display/{项目编码} 参照管理端项目大屏布局展示。

实现:抽取共享大屏组件,补充公开累计取号与最新号码字段,并完善桌面及窄屏适配。

验证:前端61项测试、TypeScript、Vite构建以及Go测试、vet、build均通过。
This commit is contained in:
2026-07-31 18:43:10 +08:00
parent d26d36c3d3
commit 3f4a9bf398
12 changed files with 489 additions and 339 deletions

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { currentBatchNumbers, forecastRows } from "../lib/display";
import type { AdminProjectDto, CallBatchDto } from "../types";
interface ProjectScreenTileProps {
project: AdminProjectDto;
standalone?: boolean;
onFullscreen?: (projectId: string) => Promise<void>;
}
export function ProjectScreenTile({ project, standalone = false, onFullscreen }: ProjectScreenTileProps) {
const current = project.current_batch && typeof project.current_batch === "object"
? project.current_batch as CallBatchDto
: null;
const waitingTicketCount = project.waiting_ticket_count ?? project.waiting_count ?? 0;
const waitingPeopleCount = project.waiting_people_count ?? 0;
const forecasts = forecastRows(current, waitingTicketCount, project.estimated_wait);
const pageCount = Math.max(1, Math.ceil(forecasts.length / 4));
const [page, setPage] = useState(0);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
setPage((value) => Math.min(value, pageCount - 1));
if (pageCount <= 1) return undefined;
const timer = window.setInterval(() => setPage((value) => (value + 1) % pageCount), 6_000);
return () => window.clearInterval(timer);
}, [pageCount]);
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1_000);
return () => window.clearInterval(timer);
}, []);
const visible = forecasts.slice(page * 4, (page + 1) * 4);
const latestTicketNumber = project.latest_ticket_number
?? forecasts.at(-1)?.range.split(" 至 ").at(-1)
?? "暂无";
const issuedTicketCount = project.issued_ticket_count ?? Number(latestTicketNumber.match(/\d+$/)?.[0] ?? 0);
const experiencedPeople = project.experienced_people ?? project.experienced_people_start ?? 0;
const dateLabel = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
}).format(now);
const timeLabel = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(now);
return (
<article className={`screen-tile${standalone ? " screen-tile--standalone" : ""}`} data-project-screen={project.id}>
<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" />
<div className="screen-tile__clock-copy">
<time dateTime={now.toISOString()}>{timeLabel}</time>
<span>{dateLabel}</span>
</div>
</div>
<div className="screen-tile__project">
<h2>{project.name}</h2>
</div>
{!standalone && onFullscreen ? (
<button className="button button--primary screen-tile__fullscreen" onClick={() => void onFullscreen(project.id)}></button>
) : null}
</header>
<div className="screen-tile__current">
<strong>{currentBatchNumbers(project.current_batch)}</strong>
<small>{project.current_batch ? "当前叫号" : "等待下一次叫号"}</small>
<div className="screen-tile__current-metrics">
<h3 className="screen-tile__metric" aria-label={`最新取号码:${latestTicketNumber}${latestTicketNumber === "暂无" ? "" : "号"}`}><span></span><strong>{latestTicketNumber}</strong>{latestTicketNumber === "暂无" ? null : <small></small>}</h3>
<div className="screen-tile__metric" aria-label={`累计取号数:${issuedTicketCount}`}><span></span><strong>{issuedTicketCount}</strong><small></small></div>
<div className="screen-tile__metric" aria-label={`累计等待人数:${waitingPeopleCount}`}><span></span><strong>{waitingPeopleCount}</strong><small></small></div>
<div className="screen-tile__metric" aria-label={`已体验人数:${experiencedPeople}`}><span></span><strong>{experiencedPeople}</strong><small></small></div>
</div>
</div>
<section className="screen-tile__forecast" aria-label={`${project.name}最新取号信息`}>
<div className="screen-tile__forecast-list-heading" aria-hidden="true"><span></span><span></span></div>
{visible.length ? (
<ol>{visible.map((item) => <li key={item.range}><strong>{item.range}</strong><span>{item.wait}</span></li>)}</ol>
) : <p></p>}
</section>
</article>
);
}