From 3f4a9bf3983952ba2c4bf9e4555403b794f582c0 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 31 Jul 2026 18:43:10 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8D=95=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=A4=A7=E5=B1=8F=E4=B8=8E=E7=AE=A1=E7=90=86=E7=AB=AF=E5=85=A8?= =?UTF-8?q?=E5=B1=8F=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:/display/{项目编码} 参照管理端项目大屏布局展示。 实现:抽取共享大屏组件,补充公开累计取号与最新号码字段,并完善桌面及窄屏适配。 验证:前端61项测试、TypeScript、Vite构建以及Go测试、vet、build均通过。 --- findings.md | 12 + progress.md | 11 + server/internal/httpapi/public.go | 13 + .../httpapi/public_integration_test.go | 17 + task_plan.md | 15 + web/src/components/ProjectScreenTile.tsx | 88 +++++ web/src/lib/display.ts | 51 +++ web/src/pages/AdminPage.tsx | 81 +--- web/src/pages/DisplayPage.test.tsx | 33 +- web/src/pages/DisplayPage.tsx | 132 ++----- web/src/styles.css | 373 +++++++++++------- web/src/types.ts | 2 + 12 files changed, 489 insertions(+), 339 deletions(-) create mode 100644 web/src/components/ProjectScreenTile.tsx create mode 100644 web/src/lib/display.ts diff --git a/findings.md b/findings.md index 5256a36..93893ca 100644 --- a/findings.md +++ b/findings.md @@ -619,3 +619,15 @@ - 修复后 1280×720:当前卡片与右侧摘要均为 `top=124, bottom=542`,最近叫号栏为 `top=558, bottom=702`,重叠量为 0,页面宽高与视口一致。 - 修复后 1920×1080:主内容与底栏重叠量为 0,页面无横纵向溢出;右侧号码区间和等待时间的测量间距为 18px,不再互相覆盖。 - 390×844 下页头切换为两行居中布局,日期和时间均落在视口内;内容使用普通纵向滚动,无横向溢出。 + +# 2026-07-31 单项目大屏复用管理端全屏布局 + +- 用户截图为 2048×1152 的 16:9 景区电视大屏,结构依次是白色时间顶栏、当前叫号主卡、四项统计、后续号段卡。 +- 截图与仓库 `AdminPage.tsx` 中 `ProjectScreenTile` 的全屏 DOM、文案和字段完全对应,不需要另造一套页面结构。 +- 线上 `/admin/display/token` 会跳转到员工页,未登录访问 `/admin/display` 当前仍显示管理端登录;线上行为不作为本地新布局的源码基准。 +- 单项目快照已有项目名、当前/最近批次、等待票数、等待人数和已体验人数,但暂未声明 `latest_ticket_number` 与 `issued_ticket_count`;这两项是参考布局的明确指标。 +- 最稳妥的复用方式是让项目大屏组件支持独立展示修饰类,并让该修饰类与 `:fullscreen` 共用相同 CSS,避免复制大段样式后发生漂移。 +- 本地代理真实 `YYHHC` 数据后,2048×1152 下顶栏、主卡和后续号段卡依次为 `0–146`、`163–594`、`611–1117`,页面滚动尺寸与视口完全一致。 +- 1280×720 下四个统计卡各约 233px 宽,内容 `scrollWidth=231px`,没有裁切;整页宽高仍与视口一致。 +- 390×844 下使用两列统计卡和较小的号段字号,号段与等待时间保留 20px 间距;页面 `scrollWidth=390`、`scrollHeight=844`,无横向或纵向溢出。 +- 浏览器日志只有 Vite 连接与热更新调试信息,无 warning 或 error。 diff --git a/progress.md b/progress.md index 056edb1..b2b2641 100644 --- a/progress.md +++ b/progress.md @@ -708,3 +708,14 @@ - 已提高右侧摘要标题对比度、将统计信息改为稳定双行,并限制号码区间字号以适应窄侧栏。 - 已使用本地页面代理真实 `YYHHC` 数据完成 1280×720、1920×1080 与 390×844 视觉验收;临时配置和本地服务均已清理。 - 前端 18 个测试文件共 61 项全部通过,TypeScript 类型检查及 Vite 生产构建成功。 + +# Session: 2026-07-31(单项目大屏复用管理端全屏布局) + +- 已核对用户截图、线上入口和仓库现有 `ProjectScreenTile`,确认目标就是管理端项目卡的全屏结构。 +- 已确定保留 `/display/:token` 的三秒轮询、断线提示与三次语音播报,只替换展示结构并补齐公开统计字段。 +- 成功标准:单项目页无需点击全屏即可显示参考布局,管理端缩略卡与全屏态不回归,桌面和窄屏无横向溢出。 +- 已抽取共享 `ProjectScreenTile`,管理端预览、管理端全屏和 `/display/:token` 现在使用同一份 DOM 与字段映射。 +- 单项目公开快照新增累计取号数与最新取号码,避免通过号段文本猜测累计值。 +- 聚焦验证通过:单项目页 3 项、公开大屏中心 2 项、管理端 10 项测试及 TypeScript 检查全部通过。 +- 已使用真实 `YYHHC` 数据完成 2048×1152、1280×720、390×844 视觉验收;三种尺寸均无页面溢出,窄屏统计文字完整可见。 +- 全量验证通过:前端 18 个测试文件共 61 项、TypeScript 双配置检查、Vite 生产构建;后端 `go test ./... -count=1`、`go vet ./...`、`go build ./...`;`git diff --check` 无格式错误。 diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index ca2a933..0ce7d3d 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -280,6 +280,8 @@ type displaySnapshotDTO struct { WaitingCount int64 `json:"waiting_count"` WaitingTicketCount int64 `json:"waiting_ticket_count"` WaitingPeopleCount int64 `json:"waiting_people_count"` + IssuedTicketCount int `json:"issued_ticket_count"` + LatestTicketNumber *string `json:"latest_ticket_number"` ExperiencedPeople int64 `json:"experienced_people"` CurrentBatch *displayBatchDTO `json:"current_batch"` RecentBatches []displayBatchDTO `json:"recent_batches"` @@ -376,11 +378,22 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { if session.UpdatedAt.After(lastUpdated) { lastUpdated = session.UpdatedAt } + issuedTicketCount := max(0, session.NextTicketNumber-1) + var latestTicketNumber *string + if issuedTicketCount > 0 { + formatted, formatErr := domain.DisplayNumber("", issuedTicketCount) + if formatErr != nil { + writeError(w, formatErr) + return + } + latestTicketNumber = &formatted + } writeJSON(w, http.StatusOK, displaySnapshotDTO{ ProjectName: project.Name, Status: project.Status, Project: displayProjectDTO{ID: project.ID, Name: project.Name, Status: project.Status}, Revision: session.Revision, WaitingCount: waiting.TicketCount, WaitingTicketCount: waiting.TicketCount, WaitingPeopleCount: waiting.PeopleCount, + IssuedTicketCount: issuedTicketCount, LatestTicketNumber: latestTicketNumber, ExperiencedPeople: experiencedPeople, CurrentBatch: batch, RecentBatches: recentBatches, EstimatedWait: estimatedWait, ServerTime: s.now(), LastUpdatedAt: lastUpdated, }) diff --git a/server/internal/httpapi/public_integration_test.go b/server/internal/httpapi/public_integration_test.go index 872ecdf..418e1b7 100644 --- a/server/internal/httpapi/public_integration_test.go +++ b/server/internal/httpapi/public_integration_test.go @@ -283,4 +283,21 @@ func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(t *testing.T) { t.Fatalf("ticket = %#v, want current session revision %d", ticket, currentSession.Revision) } } + + displayRecorder := httptest.NewRecorder() + displayRequest := httptest.NewRequest(http.MethodGet, "/api/display/"+strings.ToLower(project.Code)+"/snapshot", nil) + server.Handler().ServeHTTP(displayRecorder, displayRequest) + if displayRecorder.Code != http.StatusOK { + t.Fatalf("display status = %d, want 200; body = %s", displayRecorder.Code, displayRecorder.Body.String()) + } + var displayResponse struct { + IssuedTicketCount int `json:"issued_ticket_count"` + LatestTicketNumber *string `json:"latest_ticket_number"` + } + if err := json.Unmarshal(displayRecorder.Body.Bytes(), &displayResponse); err != nil { + t.Fatal(err) + } + if displayResponse.IssuedTicketCount != 2 || displayResponse.LatestTicketNumber == nil || *displayResponse.LatestTicketNumber != "00002" { + t.Fatalf("display metrics = %#v, want issued 2 and latest 00002", displayResponse) + } } diff --git a/task_plan.md b/task_plan.md index aa36b51..61cf4d8 100644 --- a/task_plan.md +++ b/task_plan.md @@ -617,3 +617,18 @@ Phase 47(单项目公示屏排版修复) | `planning-with-files-zh` 会话恢复脚本缺少本机 Python | 1 | 记录后跳过脚本,直接读取现有三份规划文件并核对 Git 状态 | | 将启动服务与轮询拼在同一 PowerShell 命令时被策略拒绝 | 1 | 拆分为显式 `Start-Process -WindowStyle Hidden` 和独立就绪检查 | | 通过运行时 `pnpm` 包装器启动时触发依赖安装,服务未按时就绪 | 1 | 终止本轮创建的进程,改用项目本地 `node_modules/.bin/vite.cmd` 直接启动 | + +### Phase 48: 单项目大屏复用管理端全屏布局(2026-07-31) +- [x] 核对参考截图、线上入口与现有管理端全屏组件 +- [x] 补充单项目布局与统计字段回归测试 +- [x] 复用同一套项目大屏结构并保留轮询、异常提示和语音播报 +- [x] 完成桌面、矮屏与窄屏视觉验收 +- [x] 通过前端全量测试、类型检查与生产构建 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 视觉基准 | 以用户截图和仓库内 `ProjectScreenTile` 全屏态为准 | 截图对应现有管理端项目卡全屏结构,可保持两个入口一致 | +| 改动边界 | 只复用展示组件与公开统计字段,不改轮询和语音播报 | 降低视觉重做对实时叫号行为的回归风险 | +| 适配策略 | 独立路由使用全屏样式修饰类,管理端预览仍保持缩略卡片 | `/display/YYHHC` 无需点击全屏即可获得参考布局 | diff --git a/web/src/components/ProjectScreenTile.tsx b/web/src/components/ProjectScreenTile.tsx new file mode 100644 index 0000000..52d1ce5 --- /dev/null +++ b/web/src/components/ProjectScreenTile.tsx @@ -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; +} + +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 ( +
+
+
+ +
+ + {dateLabel} +
+
+
+

{project.name}

+
+ {!standalone && onFullscreen ? ( + + ) : null} +
+
+ {currentBatchNumbers(project.current_batch)} + {project.current_batch ? "当前叫号" : "等待下一次叫号"} +
+

最新取号码:{latestTicketNumber}{latestTicketNumber === "暂无" ? null : }

+
累计取号数:{issuedTicketCount}
+
累计等待人数:{waitingPeopleCount}
+
已体验人数:{experiencedPeople}
+
+
+
+ + {visible.length ? ( +
    {visible.map((item) =>
  1. {item.range}{item.wait}
  2. )}
+ ) :

后续区间暂不可估算

} +
+
+ ); +} diff --git a/web/src/lib/display.ts b/web/src/lib/display.ts new file mode 100644 index 0000000..5547422 --- /dev/null +++ b/web/src/lib/display.ts @@ -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; + 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) : "暂无"; +} diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 146b45c..f2119df 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -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
{project.name}{deviceStatusLabel(project.device_status)} {project.waiting_people_count ?? 0}人 · {project.waiting_ticket_count ?? project.waiting_count ?? 0} 个号码 - {currentBatchLabel(project.current_batch)} + {currentBatchNumbers(project.current_batch)}
@@ -385,64 +372,6 @@ export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) { ); } -function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto; onFullscreen: (projectId: string) => Promise }) { - 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 ( -
-
-
- -
- - {dateLabel} -
-
-
-

{project.name}

-
- -
-
- {currentBatchNumbers(project.current_batch)} - {project.current_batch ? "当前叫号" : "等待下一次叫号"} -
-

最新取号码:{latestTicketNumber}{latestTicketNumber === "暂无" ? null : }

-
累计取号数:{issuedTicketCount}
-
累计等待人数:{waitingPeopleCount}
-
已体验人数:{experiencedPeople}
-
-
-
- - {visible.length ?
    {visible.map((item) =>
  1. {item.range}{item.wait}
  2. )}
:

后续区间暂不可估算

} -
-
- ); -} - function AccountManagement({ projects }: { projects: AdminProjectDto[] }) { const resource = usePollingResource((signal) => api.adminUsers(signal), { intervalMs: 30_000 }); return <> diff --git a/web/src/pages/DisplayPage.test.tsx b/web/src/pages/DisplayPage.test.tsx index 71de55c..2a5cca2 100644 --- a/web/src/pages/DisplayPage.test.tsx +++ b/web/src/pages/DisplayPage.test.tsx @@ -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(); + const { container } = render(); - 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 分钟" }, ]); }); diff --git a/web/src/pages/DisplayPage.tsx b/web/src/pages/DisplayPage.tsx index 14f78fe..94727d2 100644 --- a/web/src/pages/DisplayPage.tsx +++ b/web/src/pages/DisplayPage.tsx @@ -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; - 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
正在连接叫号服务
; } @@ -99,71 +53,31 @@ export function DisplayPage() { } if (!data) return null; - return ( -
- -
-
- 小七孔文旅集团 -

排队叫号系统

-
-
- {projectStatusMeta(data.status).label} - {new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(new Date())} - -
-
+ 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 ( +
+ {(resource.offline || stale || resource.error) ? (
{resource.offline ? "连接已中断" : "数据更新延迟"} 上次更新 {formatDateTime(freshnessTime)}
) : null} - -
-

当前请前往入口

- {current && current.tickets.length ? ( - <> -
- {formatTicketNumberRange(batchNumbers(current))} -
-
- 本次 {current.ticket_count ?? current.tickets.length} 个号码 · 共 {current.people_count ?? current.tickets.reduce((sum, ticket) => sum + ticket.party_size, 0)} 人 - 请出示排队号码 -
- - ) : ( -
暂无当前叫号
- )} -
- -
-
-

最近叫号

- {data.recent_batches?.length ? ( -
    - {data.recent_batches.slice(0, 5).map((batch) => ( -
  1. - 最近叫号 - {formatTicketNumberRange(batchNumbers(batch))} - -
  2. - ))} -
- ) :

暂无最近叫号

} -
-
-

后续号段预计

{forecastPageCount > 1 ? {forecastPage + 1} / {forecastPageCount} : null}
- {forecasts.length ?
    - {visibleForecasts.map((item) =>
  1. {item.range}{item.wait}
  2. )} -
:

后续区间暂不可估算

} -
- 等待 {waitingPeopleCount} 人 · {waitingTicketCount} 个号码 - 已体验人数:{data.experienced_people ?? 0} 人 -
-
-
+
); } diff --git a/web/src/styles.css b/web/src/styles.css index 9eba2dd..8c9bb00 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; diff --git a/web/src/types.ts b/web/src/types.ts index 691b995..573bfb9 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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;