统一单项目大屏与管理端全屏布局
需求:/display/{项目编码} 参照管理端项目大屏布局展示。
实现:抽取共享大屏组件,补充公开累计取号与最新号码字段,并完善桌面及窄屏适配。
验证:前端61项测试、TypeScript、Vite构建以及Go测试、vet、build均通过。
This commit is contained in:
88
web/src/components/ProjectScreenTile.tsx
Normal file
88
web/src/components/ProjectScreenTile.tsx
Normal 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
51
web/src/lib/display.ts
Normal 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) : "暂无";
|
||||
}
|
||||
@@ -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 <>
|
||||
|
||||
@@ -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 分钟" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user