统一单项目大屏与管理端全屏布局
需求:/display/{项目编码} 参照管理端项目大屏布局展示。
实现:抽取共享大屏组件,补充公开累计取号与最新号码字段,并完善桌面及窄屏适配。
验证:前端61项测试、TypeScript、Vite构建以及Go测试、vet、build均通过。
This commit is contained in:
12
findings.md
12
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。
|
||||
|
||||
11
progress.md
11
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` 无格式错误。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
15
task_plan.md
15
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` 无需点击全屏即可获得参考布局 |
|
||||
|
||||
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