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

需求:/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>
);
}

51
web/src/lib/display.ts Normal file
View File

@@ -0,0 +1,51 @@
import { formatTicketNumberRange } from "./format";
import type { AdminProjectDto, CallBatchDto } from "../types";
function batchNumbers(batch?: CallBatchDto | null): string[] {
return batch?.tickets.map((ticket) => ticket.ticket_number) ?? [];
}
function waitRange(value: unknown): { min: number; max: number } | null {
if (typeof value === "number" && Number.isFinite(value)) {
const minutes = Math.max(0, Math.round(value));
return { min: minutes, max: minutes };
}
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const min = Number(record.min ?? record.min_minutes ?? record.lower ?? record.lower_minutes ?? record.minutes ?? record.value);
const max = Number(record.max ?? record.max_minutes ?? record.upper ?? record.upper_minutes ?? record.minutes ?? record.value);
return Number.isFinite(min) && Number.isFinite(max)
? { min: Math.max(0, Math.round(min)), max: Math.max(0, Math.round(max)) }
: null;
}
const FORECAST_INTERVAL_SIZE = 30;
export function forecastRows(batch: CallBatchDto | null | undefined, waitingCount: number, estimatedWait: unknown) {
const called = batchNumbers(batch);
const match = called.at(-1)?.match(/^(.*?)(\d+)$/);
const wait = waitRange(estimatedWait);
if (!match || !wait || waitingCount < 1) return [];
const prefix = match[1];
const current = Number(match[2]);
const width = match[2].length;
const rowCount = Math.ceil(waitingCount / FORECAST_INTERVAL_SIZE);
return Array.from({ length: rowCount }, (_, index) => {
const startOffset = index * FORECAST_INTERVAL_SIZE + 1;
const endOffset = Math.min(waitingCount, (index + 1) * FORECAST_INTERVAL_SIZE);
const number = (offset: number) => `${prefix}${String(current + offset).padStart(width, "0")}`;
const min = Math.round(wait.min * endOffset / waitingCount);
const max = Math.round(wait.max * endOffset / waitingCount);
return {
range: startOffset === endOffset ? number(startOffset) : `${number(startOffset)} 至 ${number(endOffset)}`,
wait: min === max ? `约 ${max} 分钟` : `${min} 至 ${max} 分钟`,
};
});
}
export function currentBatchNumbers(value: AdminProjectDto["current_batch"]): string {
if (value === null || value === undefined) return "暂无";
if (typeof value === "string" || typeof value === "number") return String(value);
const numbers = value.tickets.map((ticket) => ticket.ticket_number).filter(Boolean);
return numbers.length ? formatTicketNumberRange(numbers) : "暂无";
}

View File

@@ -3,25 +3,12 @@ import { Link, NavLink, useLocation, useNavigate } from "react-router-dom";
import { ApiError, api } from "../api";
import { AppShell } from "../components/AppShell";
import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { ProjectScreenTile } from "../components/ProjectScreenTile";
import { StatusBadge } from "../components/StatusBadge";
import { usePollingResource } from "../hooks/usePollingResource";
import { formatDateTime, formatTicketNumberRange, isTimestampStale } from "../lib/format";
import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallBatchDto, CallMode } from "../types";
import { forecastRows } from "./DisplayPage";
function currentBatchLabel(value: AdminProjectDto["current_batch"]): string {
if (value === null || value === undefined) return "暂无";
if (typeof value === "string" || typeof value === "number") return String(value);
const batch = value as CallBatchDto;
return formatTicketNumberRange(batch.tickets.map((ticket) => ticket.ticket_number));
}
function currentBatchNumbers(value: AdminProjectDto["current_batch"]): string {
if (!value || typeof value === "string" || typeof value === "number") return currentBatchLabel(value);
const batch = value as CallBatchDto;
const numbers = batch.tickets.map((ticket) => ticket.ticket_number).filter(Boolean);
return numbers.length ? formatTicketNumberRange(numbers) : currentBatchLabel(batch);
}
import { currentBatchNumbers } from "../lib/display";
import { formatDateTime, isTimestampStale } from "../lib/format";
import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallMode } from "../types";
function deviceStatusLabel(value: unknown): string {
if (!value) return "未配置";
@@ -82,7 +69,7 @@ function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOvervi
<div className="queue-project-row" key={project.id} role="listitem">
<span className="queue-project-row__name"><strong>{project.name}</strong><small>{deviceStatusLabel(project.device_status)}</small></span>
<span className="queue-project-row__metric"><strong>{project.waiting_people_count ?? 0}</strong><small>人 · {project.waiting_ticket_count ?? project.waiting_count ?? 0} 个号码</small></span>
<span><strong>{currentBatchLabel(project.current_batch)}</strong></span>
<span><strong>{currentBatchNumbers(project.current_batch)}</strong></span>
<StatusBadge status={project.status} kind="project" />
<time>{formatDateTime(project.last_updated_at)}</time>
</div>
@@ -385,64 +372,6 @@ export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
);
}
function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto; onFullscreen: (projectId: string) => Promise<void> }) {
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" 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>
<button className="button button--primary screen-tile__fullscreen" onClick={() => void onFullscreen(project.id)}>全屏展示</button>
</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>
);
}
function AccountManagement({ projects }: { projects: AdminProjectDto[] }) {
const resource = usePollingResource((signal) => api.adminUsers(signal), { intervalMs: 30_000 });
return <>

View File

@@ -9,6 +9,8 @@ const { pollingResource } = vi.hoisted(() => ({
waiting_count: 0,
waiting_ticket_count: 0,
waiting_people_count: 0,
issued_ticket_count: 4,
latest_ticket_number: "00004",
experienced_people: 12,
current_batch: null,
recent_batches: [
@@ -57,20 +59,23 @@ describe("DisplayPage", () => {
vi.unstubAllGlobals();
});
it("renders public batches without requiring private batch IDs", () => {
it("直接使用管理端项目大屏的全屏布局", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
render(<DisplayPage />);
const { container } = render(<DisplayPage />);
expect(screen.getByText("00002 至 00004")).toBeVisible();
expect(screen.queryByText("00002、00003、00004")).not.toBeInTheDocument();
expect(screen.getByText("00001")).toBeVisible();
expect(screen.getByText("暂无当前叫号")).toBeVisible();
expect(screen.getByText("后续号段预计")).toBeVisible();
expect(screen.getByText("已体验人数:12 人")).toBeVisible();
expect(screen.queryByText("现场叫号")).not.toBeInTheDocument();
expect(screen.queryByText("请留意最新叫号")).not.toBeInTheDocument();
expect(screen.queryByText("请以现场工作人员指引为准")).not.toBeInTheDocument();
expect(container.querySelector(".screen-tile--standalone")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "示范项目" })).toBeVisible();
expect(screen.getByText("等待下一次叫号")).toBeVisible();
expect(screen.getByRole("heading", { name: "最新取号码:00004号" })).toBeVisible();
expect(screen.getByLabelText("累计取号数:4 个")).toBeVisible();
expect(screen.getByLabelText("累计等待人数:0 人")).toBeVisible();
expect(screen.getByLabelText("已体验人数:12 人")).toBeVisible();
expect(screen.getByText("后续号段")).toBeVisible();
expect(screen.getByText("预计等待时间")).toBeVisible();
expect(screen.getByText("后续区间暂不可估算")).toBeVisible();
expect(screen.queryByText("最近叫号")).not.toBeInTheDocument();
expect(screen.queryByText("当前请前往入口")).not.toBeInTheDocument();
expect(consoleError.mock.calls.flat().join(" ")).not.toContain("unique \"key\"");
});
@@ -83,9 +88,9 @@ describe("DisplayPage", () => {
}, 65, { min: 60, max: 90 });
expect(rows).toEqual([
{ range: "00011–00040", wait: "28–42 分钟" },
{ range: "00041–00070", wait: "55–83 分钟" },
{ range: "00071–00075", wait: "60–90 分钟" },
{ range: "00011 至 00040", wait: "28 至 42 分钟" },
{ range: "00041 至 00070", wait: "55 至 83 分钟" },
{ range: "00071 至 00075", wait: "60 至 90 分钟" },
]);
});

View File

@@ -1,46 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
import { ProjectScreenTile } from "../components/ProjectScreenTile";
import { SpeechControl } from "../components/SpeechControl";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, formatDateTime, formatTicketNumberRange, isTimestampStale, projectStatusMeta } from "../lib/format";
import type { CallBatchDto } from "../types";
import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format";
import type { AdminProjectDto } from "../types";
function batchNumbers(batch?: CallBatchDto | null): string[] {
return batch?.tickets.map((ticket) => ticket.ticket_number) ?? [];
}
function waitRange(value: unknown): { min: number; max: number } | null {
if (typeof value === "number" && Number.isFinite(value)) return { min: Math.max(0, Math.round(value)), max: Math.max(0, Math.round(value)) };
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const min = Number(record.min ?? record.min_minutes ?? record.lower ?? record.lower_minutes ?? record.minutes ?? record.value);
const max = Number(record.max ?? record.max_minutes ?? record.upper ?? record.upper_minutes ?? record.minutes ?? record.value);
return Number.isFinite(min) && Number.isFinite(max) ? { min: Math.max(0, Math.round(min)), max: Math.max(0, Math.round(max)) } : null;
}
const FORECAST_INTERVAL_SIZE = 30;
const FORECAST_PAGE_SIZE = 4;
export function forecastRows(batch: CallBatchDto | null | undefined, waitingCount: number, estimatedWait: unknown) {
const called = batchNumbers(batch);
const match = called.at(-1)?.match(/^(.*?)(\d+)$/);
const wait = waitRange(estimatedWait);
if (!match || !wait || waitingCount < 1) return [];
const prefix = match[1];
const current = Number(match[2]);
const width = match[2].length;
const rowCount = Math.ceil(waitingCount / FORECAST_INTERVAL_SIZE);
return Array.from({ length: rowCount }, (_, index) => {
const startOffset = index * FORECAST_INTERVAL_SIZE + 1;
const endOffset = Math.min(waitingCount, (index + 1) * FORECAST_INTERVAL_SIZE);
const number = (offset: number) => `${prefix}${String(current + offset).padStart(width, "0")}`;
const min = Math.round(wait.min * endOffset / waitingCount);
const max = Math.round(wait.max * endOffset / waitingCount);
return { range: startOffset === endOffset ? number(startOffset) : `${number(startOffset)}–${number(endOffset)}`, wait: min === max ? `约 ${max} 分钟` : `${min}–${max} 分钟` };
});
}
export { forecastRows } from "../lib/display";
export function DisplayPage() {
const { token = "" } = useParams();
@@ -71,20 +39,6 @@ export function DisplayPage() {
announce([currentAnnouncement]);
}, [announce, 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) : [];
const forecastPageCount = Math.max(1, Math.ceil(forecasts.length / FORECAST_PAGE_SIZE));
const [forecastPage, setForecastPage] = useState(0);
useEffect(() => {
setForecastPage((page) => Math.min(page, forecastPageCount - 1));
if (forecastPageCount <= 1) return undefined;
const timer = window.setInterval(() => setForecastPage((page) => (page + 1) % forecastPageCount), 6_000);
return () => window.clearInterval(timer);
}, [forecastPageCount]);
const visibleForecasts = forecasts.slice(forecastPage * FORECAST_PAGE_SIZE, (forecastPage + 1) * FORECAST_PAGE_SIZE);
const now = new Date();
if (resource.loading && !data) {
return <main className="display-page display-page--state"><strong>正在连接叫号服务</strong></main>;
}
@@ -99,71 +53,31 @@ export function DisplayPage() {
}
if (!data) return null;
return (
<main className="display-page">
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<header className="display-header">
<div className="display-brand">
<img src="/xiaoqikong-logo.jpg" alt="小七孔文旅集团" />
<h1>排队叫号系统</h1>
</div>
<div className="display-clock">
<span className={`display-status display-status--${projectStatusMeta(data.status).tone}`}>{projectStatusMeta(data.status).label}</span>
<strong>{new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(new Date())}</strong>
<time>{new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(now)}<br />{new Intl.DateTimeFormat("zh-CN", { weekday: "long" }).format(now)}</time>
</div>
</header>
const project: AdminProjectDto = {
id: token,
name: data.project_name,
status: data.status,
waiting_count: data.waiting_count,
waiting_ticket_count: data.waiting_ticket_count,
waiting_people_count: data.waiting_people_count,
issued_ticket_count: data.issued_ticket_count,
latest_ticket_number: data.latest_ticket_number,
experienced_people: data.experienced_people,
current_batch: data.current_batch,
estimated_wait: data.estimated_wait,
last_updated_at: data.last_updated_at,
};
return (
<main className="single-display-screen">
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
{(resource.offline || stale || resource.error) ? (
<div className="display-alert" role="status">
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>
<span>上次更新 {formatDateTime(freshnessTime)}</span>
</div>
) : null}
<section className="display-current" aria-labelledby="display-current-title">
<p id="display-current-title">当前请前往入口</p>
{current && current.tickets.length ? (
<>
<div className="display-numbers" aria-label={`当前叫号 ${formatTicketNumberRange(batchNumbers(current))}`}>
<strong>{formatTicketNumberRange(batchNumbers(current))}</strong>
</div>
<div className="display-current__meta">
<span>本次 {current.ticket_count ?? current.tickets.length} 个号码 · 共 {current.people_count ?? current.tickets.reduce((sum, ticket) => sum + ticket.party_size, 0)} 人</span>
<span>请出示排队号码</span>
</div>
</>
) : (
<div className="display-idle"><strong>暂无当前叫号</strong></div>
)}
</section>
<section className="display-secondary">
<div className="display-history">
<h2>最近叫号</h2>
{data.recent_batches?.length ? (
<ol>
{data.recent_batches.slice(0, 5).map((batch) => (
<li key={batch.id || `${batch.batch_number}-${batch.called_at ?? ""}`}>
<span>最近叫号</span>
<strong>{formatTicketNumberRange(batchNumbers(batch))}</strong>
<time>{formatDateTime(batch.called_at)}</time>
</li>
))}
</ol>
) : <p>暂无最近叫号</p>}
</div>
<div className="display-queue-summary">
<div className="display-forecast__heading"><h2>后续号段预计</h2>{forecastPageCount > 1 ? <span>{forecastPage + 1} / {forecastPageCount}</span> : null}</div>
{forecasts.length ? <ol className="display-forecast">
{visibleForecasts.map((item) => <li key={item.range}><strong>{item.range}</strong><span>{item.wait}</span></li>)}
</ol> : <p className="display-forecast__empty">后续区间暂不可估算</p>}
<footer>
<span>等待 {waitingPeopleCount} 人 · {waitingTicketCount} 个号码</span>
<span aria-label={`已体验人数 ${data.experienced_people ?? 0} 人`}>已体验人数:{data.experienced_people ?? 0} 人</span>
</footer>
</div>
</section>
<ProjectScreenTile project={project} standalone />
</main>
);
}

View File

@@ -3197,7 +3197,7 @@ tbody tr:hover {
flex: 0 0 auto;
}
.screen-tile:fullscreen {
.screen-tile:is(:fullscreen, .screen-tile--standalone) {
width: 100vw;
min-height: 100dvh;
align-content: center;
@@ -3210,12 +3210,12 @@ tbody tr:hover {
#f5f9f6;
}
.screen-tile:fullscreen h2 { font-size: clamp(2.4rem, 4vw, 5rem); }
.screen-tile:fullscreen .screen-tile__current strong { font-size: clamp(5rem, 12vw, 12rem); }
.screen-tile:fullscreen .screen-tile__current small,
.screen-tile:fullscreen dt { font-size: clamp(1.25rem, 2vw, 2.2rem); }
.screen-tile:fullscreen dd { font-size: clamp(2rem, 4vw, 4.5rem); }
.screen-tile:fullscreen .screen-tile__fullscreen { display: none; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) h2 { font-size: clamp(2.4rem, 4vw, 5rem); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current strong { font-size: clamp(5rem, 12vw, 12rem); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current small,
.screen-tile:is(:fullscreen, .screen-tile--standalone) dt { font-size: clamp(1.25rem, 2vw, 2.2rem); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) dd { font-size: clamp(2rem, 4vw, 4.5rem); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__fullscreen { display: none; }
.screen-tile header > div {
display: grid;
@@ -3268,9 +3268,9 @@ tbody tr:hover {
.screen-tile__summary { display: flex; justify-content: space-between; gap: 16px; color: var(--color-text-secondary); }
.screen-tile__summary strong { color: var(--color-primary-deep); }
.screen-tile:fullscreen { grid-template-columns: minmax(0,1fr); grid-template-rows: auto minmax(0,.85fr) minmax(250px,1fr); align-content: stretch; }
.screen-tile:fullscreen > header { grid-column: 1 / -1; }
.screen-tile:fullscreen > .screen-tile__header {
.screen-tile:is(:fullscreen, .screen-tile--standalone) { grid-template-columns: minmax(0,1fr); grid-template-rows: auto minmax(0,.85fr) minmax(250px,1fr); align-content: stretch; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) > header { grid-column: 1 / -1; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
display: grid;
grid-template-columns: minmax(280px, auto) minmax(0, 1fr);
align-items: end;
@@ -3278,11 +3278,11 @@ tbody tr:hover {
border-bottom: 1px solid rgba(11,107,58,.22);
padding-bottom: clamp(18px, 2.5vh, 30px);
}
.screen-tile:fullscreen .screen-tile__clock {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock {
display: grid !important;
gap: 8px;
}
.screen-tile:fullscreen .screen-tile__clock time {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock time {
color: #063f22;
font-size: clamp(2.4rem, 4vw, 4.8rem);
font-weight: 900;
@@ -3290,27 +3290,27 @@ tbody tr:hover {
letter-spacing: -.035em;
line-height: .9;
}
.screen-tile:fullscreen .screen-tile__clock span {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock span {
color: #53635a;
font-size: clamp(1rem, 1.25vw, 1.4rem);
font-weight: 700;
letter-spacing: .04em;
}
.screen-tile:fullscreen .screen-tile__project {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project {
display: grid;
justify-items: end;
gap: 8px;
min-width: 0;
text-align: right;
}
.screen-tile:fullscreen .screen-tile__project > span {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project > span {
display: block;
color: #607068;
font-size: clamp(.9rem, 1.1vw, 1.2rem);
font-weight: 800;
letter-spacing: .12em;
}
.screen-tile:fullscreen .screen-tile__project h2 {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 {
max-width: 18ch;
overflow-wrap: anywhere;
color: #0b2d1b;
@@ -3318,16 +3318,16 @@ tbody tr:hover {
line-height: 1.05;
letter-spacing: -.035em;
}
.screen-tile:fullscreen .screen-tile__current { grid-column: 1; align-content: center; justify-items: center; min-height: 0; border: 1px solid rgba(11,107,58,.22); border-radius: 18px; padding: clamp(24px,3vw,48px); background: rgba(255,255,255,.82); text-align: center; }
.screen-tile:fullscreen .screen-tile__current small { color: #53635a; }
.screen-tile:fullscreen .screen-tile__current strong { color: #064624; font-size: clamp(5rem,10vw,10rem); }
.screen-tile:fullscreen .screen-tile__forecast { grid-column: 1; align-self: stretch; min-height: 0; border: 1px solid rgba(11,107,58,.22); border-radius: 18px; padding: clamp(22px,2.5vw,40px); background: rgba(255,255,255,.82); }
.screen-tile:fullscreen .screen-tile__forecast-heading .screen-tile__metric { gap: .22em; font-size: clamp(1.45rem,2vw,2.4rem); }
.screen-tile:fullscreen .screen-tile__forecast-heading .screen-tile__metric > strong { color: #075d31; font-size: 1.35em; }
.screen-tile:fullscreen .screen-tile__forecast li { padding: clamp(12px,1.6vh,22px) 0; }
.screen-tile:fullscreen .screen-tile__forecast ol { min-height: 0; height: calc(100% - 60px); }
.screen-tile:fullscreen .screen-tile__forecast li strong { font-size: clamp(1.8rem,2.7vw,3.35rem); white-space: nowrap; }
.screen-tile:fullscreen .screen-tile__forecast li span { font-size: clamp(1.5rem,2vw,2.5rem); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current { grid-column: 1; align-content: center; justify-items: center; min-height: 0; border: 1px solid rgba(11,107,58,.22); border-radius: 18px; padding: clamp(24px,3vw,48px); background: rgba(255,255,255,.82); text-align: center; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current small { color: #53635a; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current strong { color: #064624; font-size: clamp(5rem,10vw,10rem); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast { grid-column: 1; align-self: stretch; min-height: 0; border: 1px solid rgba(11,107,58,.22); border-radius: 18px; padding: clamp(22px,2.5vw,40px); background: rgba(255,255,255,.82); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-heading .screen-tile__metric { gap: .22em; font-size: clamp(1.45rem,2vw,2.4rem); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-heading .screen-tile__metric > strong { color: #075d31; font-size: 1.35em; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li { padding: clamp(12px,1.6vh,22px) 0; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol { min-height: 0; height: calc(100% - 60px); }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li strong { font-size: clamp(1.8rem,2.7vw,3.35rem); white-space: nowrap; }
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li span { font-size: clamp(1.5rem,2vw,2.5rem); }
.screen-tile dl {
display: grid;
@@ -5813,7 +5813,7 @@ body:has(.visitor-page--mobile) {
url("/xiaoqikong-water-background.jpg") center 43% / cover fixed no-repeat;
}
.screen-tile:fullscreen {
.screen-tile:is(:fullscreen, .screen-tile--standalone) {
background:
linear-gradient(145deg, rgba(244, 247, 243, .5) 0%, rgba(244, 247, 243, .6) 58%, rgba(231, 242, 234, .68) 100%),
url("/xiaoqikong-water-background.jpg") center 43% / cover no-repeat;
@@ -5833,7 +5833,7 @@ body:has(.visitor-page--mobile) {
/* Fullscreen public display: one horizontal status bar and three opaque,
calm information surfaces. */
.screen-tile:fullscreen > .screen-tile__header {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -5845,7 +5845,7 @@ body:has(.visitor-page--mobile) {
background: #fff;
}
.screen-tile:fullscreen .screen-tile__clock {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock {
display: flex !important;
flex: 1 1 auto;
min-width: 0;
@@ -5854,28 +5854,28 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:fullscreen .screen-tile__clock time {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock time {
flex: 0 0 auto;
font-size: clamp(2.8rem, 4.5vw, 5.6rem);
line-height: 1;
letter-spacing: -.045em;
}
.screen-tile:fullscreen .screen-tile__clock span {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock span {
flex: 0 0 auto;
font-size: clamp(.95rem, 1.2vw, 1.5rem);
line-height: 1.2;
white-space: nowrap;
}
.screen-tile:fullscreen .screen-tile__project {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project {
display: block;
flex: 0 0 auto;
max-width: min(42vw, 680px);
text-align: right;
}
.screen-tile:fullscreen .screen-tile__project h2 {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 {
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
@@ -5883,19 +5883,19 @@ body:has(.visitor-page--mobile) {
font-size: clamp(2rem, 3vw, 3.8rem);
}
.screen-tile:fullscreen > .screen-tile__current,
.screen-tile:fullscreen > .screen-tile__forecast {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current,
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
border: 1px solid rgba(11, 107, 58, .14);
background: #fff;
box-shadow: 0 16px 36px rgba(16, 38, 29, .08);
}
.screen-tile:fullscreen > .screen-tile__current {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current {
gap: clamp(14px, 2vh, 26px);
padding: clamp(28px, 4vw, 64px);
}
.screen-tile:fullscreen .screen-tile__current strong {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current strong {
color: #075d31;
font-size: clamp(5.8rem, 10vw, 12rem);
font-weight: 800;
@@ -5905,7 +5905,7 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:fullscreen .screen-tile__current small {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current small {
color: #536d5c;
font-size: clamp(1.25rem, 1.8vw, 2.2rem);
font-weight: 750;
@@ -5913,36 +5913,36 @@ body:has(.visitor-page--mobile) {
line-height: 1.2;
}
.screen-tile:fullscreen > .screen-tile__forecast {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
padding: clamp(28px, 3vw, 48px);
}
.screen-tile:fullscreen .screen-tile__forecast > div {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast > div {
border-bottom: 0;
padding-bottom: 0;
}
.screen-tile:fullscreen .screen-tile__forecast ol {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol {
grid-template-rows: repeat(4, minmax(0, auto));
height: auto;
gap: clamp(14px, 1.8vh, 28px);
margin-top: clamp(22px, 3vh, 34px);
}
.screen-tile:fullscreen .screen-tile__forecast li {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li {
min-height: clamp(44px, 6vh, 74px);
border-bottom: 0;
padding: 0;
}
.screen-tile:fullscreen .screen-tile__forecast li strong {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li strong {
color: #183a27;
font-size: clamp(1.8rem, 2.7vw, 3.35rem);
font-variant-numeric: tabular-nums;
letter-spacing: -.025em;
}
.screen-tile:fullscreen .screen-tile__forecast li span {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li span {
color: #247447;
font-size: clamp(1.5rem, 2vw, 2.5rem);
font-variant-numeric: tabular-nums;
@@ -5950,12 +5950,12 @@ body:has(.visitor-page--mobile) {
/* Fullscreen title bar: edge-to-edge at the viewport top, with the clock
first and the scenic identity immediately after it. */
.screen-tile:fullscreen {
.screen-tile:is(:fullscreen, .screen-tile--standalone) {
padding: 0 0 clamp(32px, 5vh, 72px);
gap: clamp(20px, 3vh, 40px);
}
.screen-tile:fullscreen > .screen-tile__header {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
width: 100%;
border-top: 0;
border-right: 0;
@@ -5964,18 +5964,18 @@ body:has(.visitor-page--mobile) {
padding: clamp(16px, 2.2vw, 28px) clamp(24px, 5vw, 96px);
}
.screen-tile:fullscreen > .screen-tile__current,
.screen-tile:fullscreen > .screen-tile__forecast {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current,
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
margin-inline: clamp(24px, 5vw, 96px);
}
.screen-tile:fullscreen .screen-tile__project {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project {
display: flex;
align-items: center;
gap: clamp(12px, 1.4vw, 24px);
}
.screen-tile:fullscreen .screen-tile__logo {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__logo {
width: clamp(64px, 6vw, 116px);
height: clamp(42px, 4.5vw, 78px);
flex: 0 0 auto;
@@ -5993,7 +5993,7 @@ body:has(.visitor-page--mobile) {
background: #f1f6f2;
}
.screen-tile:not(:fullscreen) {
.screen-tile:not(:fullscreen, .screen-tile--standalone) {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, .92fr) minmax(0, 1fr);
@@ -6010,7 +6010,7 @@ body:has(.visitor-page--mobile) {
box-shadow: 0 14px 34px rgba(16, 38, 29, .08);
}
.screen-tile:not(:fullscreen) > .screen-tile__header {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: center;
@@ -6022,7 +6022,7 @@ body:has(.visitor-page--mobile) {
background: #fff;
}
.screen-tile:not(:fullscreen) .screen-tile__clock {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__clock {
display: flex !important;
min-width: 0;
align-items: center;
@@ -6030,7 +6030,7 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:not(:fullscreen) .screen-tile__clock time {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__clock time {
color: #075d31;
font-size: clamp(1.2rem, 2vw, 2rem);
font-weight: 850;
@@ -6038,7 +6038,7 @@ body:has(.visitor-page--mobile) {
font-variant-numeric: tabular-nums;
}
.screen-tile:not(:fullscreen) .screen-tile__clock span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__clock span {
overflow: hidden;
color: #637369;
font-size: clamp(.62rem, .9vw, .82rem);
@@ -6047,7 +6047,7 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:not(:fullscreen) .screen-tile__project {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__project {
display: flex;
min-width: 0;
align-items: center;
@@ -6056,7 +6056,7 @@ body:has(.visitor-page--mobile) {
text-align: right;
}
.screen-tile:not(:fullscreen) .screen-tile__logo {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__logo {
width: clamp(38px, 4vw, 62px);
height: clamp(26px, 3vw, 42px);
flex: 0 0 auto;
@@ -6064,7 +6064,7 @@ body:has(.visitor-page--mobile) {
mix-blend-mode: multiply;
}
.screen-tile:not(:fullscreen) .screen-tile__project h2 {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 {
max-width: 20ch;
overflow: hidden;
color: #123b25;
@@ -6074,7 +6074,7 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:not(:fullscreen) .screen-tile__fullscreen {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__fullscreen {
flex: 0 0 auto;
min-height: 32px;
border-radius: 8px;
@@ -6083,8 +6083,8 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:not(:fullscreen) > .screen-tile__current,
.screen-tile:not(:fullscreen) > .screen-tile__forecast {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__current,
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
min-height: 0;
margin-inline: clamp(12px, 2vw, 24px);
border: 1px solid rgba(11, 107, 58, .14);
@@ -6092,7 +6092,7 @@ body:has(.visitor-page--mobile) {
box-shadow: 0 8px 20px rgba(16, 38, 29, .06);
}
.screen-tile:not(:fullscreen) > .screen-tile__current {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__current {
align-content: center;
justify-items: center;
gap: 6px;
@@ -6101,7 +6101,7 @@ body:has(.visitor-page--mobile) {
text-align: center;
}
.screen-tile:not(:fullscreen) > .screen-tile__current > strong {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__current > strong {
color: #075d31;
font-size: clamp(2.25rem, 4.6vw, 5.5rem);
font-weight: 800;
@@ -6111,30 +6111,30 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:not(:fullscreen) .screen-tile__current small {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current small {
color: #627269;
font-size: clamp(.72rem, 1vw, 1rem);
font-weight: 750;
letter-spacing: .12em;
}
.screen-tile:not(:fullscreen) > .screen-tile__forecast {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
overflow: hidden;
border-radius: 12px;
padding: clamp(12px, 1.5vw, 22px);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast > div {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast > div {
border-bottom: 0;
padding-bottom: 0;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast-heading .screen-tile__metric {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-heading .screen-tile__metric {
gap: .2em;
font-size: clamp(.7rem, 1vw, 1rem);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast ol {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol {
grid-template-rows: repeat(4, minmax(0, auto));
min-height: 0;
height: auto;
@@ -6142,19 +6142,19 @@ body:has(.visitor-page--mobile) {
margin-top: clamp(10px, 1.2vw, 16px);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li {
min-height: 0;
border-bottom: 0;
padding: 0;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li strong {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li strong {
color: #183a27;
font-size: clamp(.78rem, 1.4vw, 1.35rem);
font-variant-numeric: tabular-nums;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li span {
color: #247447;
font-size: clamp(.7rem, 1.1vw, 1.1rem);
font-variant-numeric: tabular-nums;
@@ -6165,42 +6165,42 @@ body:has(.visitor-page--mobile) {
grid-template-columns: 1fr;
}
.screen-tile:not(:fullscreen) {
.screen-tile:not(:fullscreen, .screen-tile--standalone) {
aspect-ratio: 16 / 10;
}
}
/* Keep miniature typography proportional to each preview card, not the
browser viewport. This prevents large ranges from being cropped. */
.screen-tile:not(:fullscreen) {
.screen-tile:not(:fullscreen, .screen-tile--standalone) {
container: screen-tile / inline-size;
}
.screen-tile:not(:fullscreen) > .screen-tile__header {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
gap: 8px;
padding: clamp(7px, 1.4cqw, 12px) clamp(9px, 1.8cqw, 16px);
}
.screen-tile:not(:fullscreen) .screen-tile__clock {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__clock {
flex: 0 1 auto;
width: 100%;
gap: clamp(5px, 1cqw, 8px);
}
.screen-tile:not(:fullscreen) .screen-tile__clock time {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__clock time {
font-size: clamp(1rem, 5.1cqw, 1.8rem);
}
.screen-tile:not(:fullscreen) .screen-tile__clock span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__clock span {
max-width: 14ch;
font-size: clamp(.58rem, 2.1cqw, .82rem);
}
.screen-tile:not(:fullscreen) .screen-tile__clock-copy span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__clock-copy span {
display: none;
}
.screen-tile:not(:fullscreen) .screen-tile__project {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__project {
display: flex;
width: 100%;
flex: 0 1 auto;
@@ -6209,30 +6209,30 @@ body:has(.visitor-page--mobile) {
justify-content: flex-end;
}
.screen-tile:not(:fullscreen) .screen-tile__logo {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__logo {
width: clamp(34px, 8cqw, 62px);
height: clamp(24px, 5.5cqw, 42px);
}
.screen-tile:not(:fullscreen) .screen-tile__project h2 {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 {
min-width: 0;
max-width: 14ch;
font-size: clamp(.78rem, 3.2cqw, 1.35rem);
text-align: right;
}
.screen-tile:not(:fullscreen) .screen-tile__fullscreen {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__fullscreen {
min-height: clamp(28px, 5.8cqw, 36px);
padding: 4px clamp(6px, 1.4cqw, 10px);
font-size: clamp(.58rem, 2cqw, .72rem);
}
.screen-tile:not(:fullscreen) > .screen-tile__current {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__current {
gap: clamp(4px, 1cqw, 8px);
padding: clamp(9px, 2.2cqw, 18px);
}
.screen-tile:not(:fullscreen) > .screen-tile__current > strong {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__current > strong {
max-width: 100%;
min-height: 0;
overflow: hidden;
@@ -6242,17 +6242,17 @@ body:has(.visitor-page--mobile) {
text-overflow: ellipsis;
}
.screen-tile:not(:fullscreen) .screen-tile__current small {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current small {
font-size: clamp(.6rem, 2.1cqw, .9rem);
}
.screen-tile:not(:fullscreen) > .screen-tile__forecast {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: clamp(9px, 2.1cqw, 20px);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast-heading .screen-tile__metric {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-heading .screen-tile__metric {
min-width: 0;
overflow: hidden;
font-size: clamp(.58rem, 2.3cqw, .9rem);
@@ -6260,11 +6260,11 @@ body:has(.visitor-page--mobile) {
white-space: nowrap;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast-heading .screen-tile__metric > strong {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-heading .screen-tile__metric > strong {
font-size: 1.25em;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast ol {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol {
grid-template-rows: repeat(4, minmax(0, 1fr));
height: 100%;
gap: clamp(4px, .9cqw, 10px);
@@ -6272,47 +6272,47 @@ body:has(.visitor-page--mobile) {
overflow: hidden;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li {
min-height: 0;
align-items: center;
line-height: 1;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li strong {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li strong {
font-size: clamp(.68rem, 3.2cqw, 1.25rem);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li span {
font-size: clamp(.62rem, 2.6cqw, 1.05rem);
}
/* Preview cards have less vertical space than the fullscreen display. Keep
all forecast rows inside the miniature instead of clipping the last row. */
.screen-tile:not(:fullscreen) > .screen-tile__forecast {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
padding: clamp(6px, 1.2cqw, 12px);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast-list-heading {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-list-heading {
margin-top: 0;
font-size: clamp(.5rem, 1.55cqw, .72rem);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast ol {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol {
gap: clamp(2px, .45cqw, 4px);
margin-top: clamp(3px, .7cqw, 6px);
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li strong {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li strong {
font-size: clamp(.55rem, 2.4cqw, 1rem);
line-height: 1;
}
.screen-tile:not(:fullscreen) .screen-tile__forecast li span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li span {
font-size: clamp(.5rem, 2cqw, .86rem);
line-height: 1;
}
.screen-tile:not(:fullscreen) .screen-tile__current-metrics .screen-tile__metric > span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric > span {
overflow: visible;
font-size: clamp(.46rem, 1.55cqw, .66rem);
text-overflow: clip;
@@ -6320,7 +6320,7 @@ body:has(.visitor-page--mobile) {
/* Fullscreen fit: keep every module inside the viewport and let the forecast
rows share the remaining height instead of pushing the last row below it. */
.screen-tile:fullscreen {
.screen-tile:is(:fullscreen, .screen-tile--standalone) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
@@ -6329,14 +6329,14 @@ body:has(.visitor-page--mobile) {
url("/xiaoqikong-water-background.jpg") center 43% / cover no-repeat;
}
.screen-tile:fullscreen > .screen-tile__forecast {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.screen-tile:fullscreen .screen-tile__forecast ol {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol {
grid-template-rows: repeat(4, minmax(0, 1fr));
height: 100%;
min-height: 0;
@@ -6345,7 +6345,7 @@ body:has(.visitor-page--mobile) {
overflow: hidden;
}
.screen-tile:fullscreen .screen-tile__forecast li {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast li {
min-height: 0;
align-items: center;
padding: 0;
@@ -6378,52 +6378,52 @@ body:has(.visitor-page--mobile) {
text-align: right;
}
.screen-tile:not(:fullscreen) > .screen-tile__forecast {
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
grid-template-rows: auto auto minmax(0, 1fr);
}
.screen-tile:fullscreen > .screen-tile__forecast {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
grid-template-rows: auto auto minmax(0, 1fr);
}
.screen-tile:fullscreen .screen-tile__forecast-list-heading {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-list-heading {
margin-top: clamp(14px, 1.8vh, 22px);
font-size: clamp(.95rem, 1.4vw, 1.35rem);
}
.screen-tile:fullscreen .screen-tile__forecast ol {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol {
margin-top: clamp(10px, 1.5vh, 20px);
}
/* Pull the forecast surface upward slightly and recover bottom breathing room
for the last rotating row. */
.screen-tile:fullscreen {
.screen-tile:is(:fullscreen, .screen-tile--standalone) {
gap: clamp(10px, 1.5vh, 22px);
padding-bottom: clamp(20px, 3vh, 48px);
}
.screen-tile:fullscreen > .screen-tile__forecast {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__forecast {
padding-top: clamp(18px, 2vw, 32px);
padding-bottom: clamp(18px, 2vw, 32px);
}
.screen-tile:fullscreen .screen-tile__forecast-list-heading {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast-list-heading {
margin-top: clamp(8px, 1vh, 14px);
}
.screen-tile:fullscreen .screen-tile__forecast ol {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__forecast ol {
margin-top: clamp(6px, 1vh, 12px);
}
/* The current-call label belongs to the module corner; the number remains the
visual focus in the center. */
.screen-tile:fullscreen > .screen-tile__current,
.screen-tile:not(:fullscreen) > .screen-tile__current {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current,
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__current {
position: relative;
}
.screen-tile:fullscreen .screen-tile__current > small,
.screen-tile:not(:fullscreen) .screen-tile__current > small {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current > small,
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current > small {
position: absolute;
top: clamp(12px, 1.6vw, 26px);
left: clamp(14px, 2vw, 32px);
@@ -6432,15 +6432,15 @@ body:has(.visitor-page--mobile) {
line-height: 1.2;
}
.screen-tile:not(:fullscreen) .screen-tile__current > small {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current > small {
top: clamp(8px, 1.4cqw, 14px);
left: clamp(10px, 1.8cqw, 18px);
font-size: clamp(.58rem, 2.1cqw, .9rem);
}
/* Summary metrics now live inside the current-call surface. */
.screen-tile:fullscreen > .screen-tile__current,
.screen-tile:not(:fullscreen) > .screen-tile__current {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current,
.screen-tile:not(:fullscreen, .screen-tile--standalone) > .screen-tile__current {
grid-template-rows: minmax(0, 1fr) auto;
}
@@ -6498,59 +6498,59 @@ body:has(.visitor-page--mobile) {
letter-spacing: 0;
}
.screen-tile:not(:fullscreen) .screen-tile__current-metrics {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics {
gap: clamp(4px, .9cqw, 9px);
padding-top: clamp(4px, 1cqw, 10px);
}
.screen-tile:not(:fullscreen) .screen-tile__current-metrics .screen-tile__metric {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric {
min-height: clamp(30px, 7.5cqw, 46px);
border-radius: clamp(7px, 1.1cqw, 11px);
padding: clamp(4px, .8cqw, 8px) clamp(5px, 1cqw, 10px);
box-shadow: 0 4px 10px rgba(16, 38, 29, .05);
}
.screen-tile:not(:fullscreen) .screen-tile__current-metrics .screen-tile__metric > span {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric > span {
font-size: clamp(.52rem, 1.8cqw, .72rem);
}
.screen-tile:not(:fullscreen) .screen-tile__current-metrics .screen-tile__metric > strong {
.screen-tile:not(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric > strong {
font-size: clamp(.74rem, 3.2cqw, 1.2rem);
}
/* Give the fullscreen current-call card more vertical breathing room after
the two summary metrics moved into it. */
.screen-tile:fullscreen > .screen-tile__current {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current {
gap: clamp(24px, 3vh, 44px);
padding: clamp(34px, 4.5vh, 68px) clamp(32px, 4vw, 68px) clamp(28px, 3.5vh, 54px);
}
.screen-tile:fullscreen .screen-tile__current strong {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current strong {
font-size: clamp(5.2rem, 8.4vw, 10rem);
line-height: .94;
}
.screen-tile:fullscreen .screen-tile__current-metrics {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics {
gap: clamp(18px, 3vw, 48px);
padding-top: clamp(16px, 2.2vh, 30px);
}
.screen-tile:fullscreen .screen-tile__current-metrics .screen-tile__metric > span {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric > span {
font-size: clamp(.82rem, 1.1vw, 1.15rem);
}
.screen-tile:fullscreen .screen-tile__current-metrics .screen-tile__metric > strong {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__current-metrics .screen-tile__metric > strong {
font-size: clamp(.95rem, 1.8vw, 1.8rem);
}
/* The font's cap-height sits above the centre of its line box. Offset by the
number's own font size so the visible digits, rather than the font metrics,
are centred in the available panel at every fullscreen resolution. */
.screen-tile:fullscreen > .screen-tile__current {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current {
gap: clamp(14px, 1.8vh, 24px);
}
.screen-tile:fullscreen > .screen-tile__current > strong {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__current > strong {
place-self: center;
margin: 0;
text-align: center;
@@ -6591,18 +6591,18 @@ body:has(.visitor-page--mobile) {
/* Fullscreen project names are content, not decoration: let them wrap into
the space available beside the clock instead of clipping with an ellipsis. */
.screen-tile:fullscreen > .screen-tile__header {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 48%);
align-items: center;
gap: clamp(16px, 3vw, 56px);
}
.screen-tile:fullscreen .screen-tile__clock {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock {
min-width: 0;
}
.screen-tile:fullscreen .screen-tile__project {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project {
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-self: end;
@@ -6613,7 +6613,7 @@ body:has(.visitor-page--mobile) {
gap: clamp(10px, 1.4vw, 24px);
}
.screen-tile:fullscreen .screen-tile__project h2 {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 {
min-width: 0;
max-width: none;
overflow: visible;
@@ -6625,42 +6625,135 @@ body:has(.visitor-page--mobile) {
}
@media (max-width: 760px), (max-aspect-ratio: 4 / 3) {
.screen-tile:fullscreen > .screen-tile__header {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
gap: clamp(10px, 2vw, 24px);
padding-inline: clamp(16px, 4vw, 48px);
}
.screen-tile:fullscreen .screen-tile__clock {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock {
gap: clamp(8px, 1.5vw, 16px);
}
.screen-tile:fullscreen .screen-tile__clock time {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock time {
font-size: clamp(2rem, 4vw, 4rem);
}
.screen-tile:fullscreen .screen-tile__clock span {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__clock span {
white-space: normal;
}
.screen-tile:fullscreen .screen-tile__project h2 {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 {
font-size: clamp(1.25rem, 3vw, 2.8rem);
}
}
@media (max-width: 560px) {
.screen-tile:fullscreen > .screen-tile__header {
.screen-tile:is(:fullscreen, .screen-tile--standalone) > .screen-tile__header {
grid-template-columns: minmax(0, 1fr);
}
.screen-tile:fullscreen .screen-tile__project {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project {
justify-self: stretch;
}
.screen-tile:fullscreen .screen-tile__project h2 {
.screen-tile:is(:fullscreen, .screen-tile--standalone) .screen-tile__project h2 {
text-align: left;
}
}
.single-display-screen {
min-height: 100dvh;
overflow: hidden;
background: #eef5ef;
color: #10251a;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.single-display-screen > .display-alert {
position: fixed;
z-index: 40;
top: clamp(92px, 11vh, 132px);
left: 50%;
width: max-content;
max-width: calc(100vw - 32px);
transform: translateX(-50%);
box-shadow: 0 10px 30px rgba(16, 38, 29, .18);
}
@media (max-width: 560px) {
.single-display-screen {
overflow: visible;
}
.screen-tile.screen-tile--standalone {
height: auto;
max-height: none;
min-height: 100dvh;
overflow: visible;
grid-template-rows: auto minmax(360px, auto) minmax(260px, auto);
padding-bottom: 16px;
}
.screen-tile.screen-tile--standalone > .screen-tile__current,
.screen-tile.screen-tile--standalone > .screen-tile__forecast {
margin-inline: 12px;
}
.screen-tile.screen-tile--standalone > .screen-tile__current {
gap: 14px;
padding: 48px 16px 20px;
}
.screen-tile.screen-tile--standalone > .screen-tile__current > strong {
font-size: clamp(3.25rem, 18vw, 5rem);
}
.screen-tile.screen-tile--standalone .screen-tile__current-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.screen-tile.screen-tile--standalone .screen-tile__current-metrics .screen-tile__metric {
display: grid;
grid-template-columns: auto auto;
align-content: center;
justify-content: center;
min-height: 58px;
padding: 8px 6px;
}
.screen-tile.screen-tile--standalone .screen-tile__current-metrics .screen-tile__metric > span {
grid-column: 1 / -1;
overflow: visible;
font-size: .68rem;
text-overflow: clip;
}
.screen-tile.screen-tile--standalone .screen-tile__current-metrics .screen-tile__metric > strong {
font-size: 1rem;
}
.screen-tile.screen-tile--standalone .screen-tile__forecast li {
gap: 20px;
}
.screen-tile.screen-tile--standalone .screen-tile__forecast li strong {
font-size: clamp(1.3rem, 6.4vw, 1.6rem);
}
.screen-tile.screen-tile--standalone .screen-tile__forecast li span {
font-size: clamp(1rem, 5.2vw, 1.3rem);
}
.single-display-screen > .display-alert {
top: 12px;
flex-direction: column;
gap: 2px;
text-align: center;
}
}
/* Historical operations review dashboard */
.history-dashboard {
display: grid;

View File

@@ -184,6 +184,8 @@ export interface DisplaySnapshotDto {
waiting_count: number;
waiting_ticket_count: number;
waiting_people_count: number;
issued_ticket_count?: number;
latest_ticket_number?: string | null;
experienced_people: number;
estimated_wait?: unknown;
last_updated_at?: string | null;