Merge pull request #510 from pikasTech/fix/509-web-timezone

fix: drive frontend timezone from YAML config
This commit is contained in:
Lyon
2026-06-20 13:01:26 +08:00
committed by GitHub
12 changed files with 239 additions and 55 deletions
+8
View File
@@ -0,0 +1,8 @@
version: 1
kind: UniDeskFrontendConfig
metadata:
name: unidesk-frontend
displayTime:
timeZone: Asia/Shanghai
locale: zh-CN
label: 北京时间
+3 -3
View File
@@ -27,9 +27,9 @@
- Instances can scale horizontally; failure recovery requires no state synchronization
- Only the production frontend gateway, dev frontend proxy and provider ingress are unrestricted public entries; core REST APIs and PostgreSQL remain on the Docker internal network or explicitly restricted host mappings. The dev frontend proxy rule is owned by `docs/reference/dev-environment.md`.
- Frontend Time Zone Policy
- All UniDesk frontend timestamps, dates, clocks, update times, heartbeat times, Trace times, Gantt axis labels, export date stamps, and `datetime-local` values must render as Beijing time.
- Beijing time means IANA timezone `Asia/Shanghai` / UTC+8, regardless of the browser timezone, host system timezone, container timezone, or server-side `project.timezone` value.
- Frontend code must use the shared formatter and input conversion helpers in `src/components/frontend/src/time.ts`; raw ISO/UTC timestamps may appear only inside explicitly opened raw JSON views.
- All UniDesk frontend timestamps, dates, clocks, update times, heartbeat times, Trace times, Gantt axis labels, export date stamps, and `datetime-local` values must render from the single `config/frontend.yaml` `displayTime` source.
- Frontend code must use the shared formatter and input conversion helpers in `src/components/frontend/src/time.ts`; these helpers read only the server-injected `data-config.displayTime` and do not fall back to browser, host, env, `config.json.project.timezone`, or hardcoded timezone values.
- Upstream services and backend APIs should keep machine timestamps such as ISO/UTC values; Web rendering is the only layer that applies the configured display timezone. Raw ISO/UTC timestamps may appear only inside explicitly opened raw JSON views.
- PostgreSQL Database
- Deployed as a Docker container with a 10 GB named volume
- Stores all task metadata, node heartbeats, resource labels, and business state
+5 -1
View File
@@ -37,7 +37,11 @@ Code Queue 的 queue 合并弹窗是该公共组件的首个业务复用示例
## Time Zone Contract
frontend 所有默认可见的时间、日期、时钟、更新时间、心跳时间、Trace 时间、Gantt 时间轴刻度、导出文件日期和 `datetime-local` 输入值都必须按北京时间显示,即使用 IANA 时区 `Asia/Shanghai` / UTC+8。禁止依赖浏览器本地时区、服务器系统时区或裸 `Date.toLocaleString()` 默认值;新增页面必须复用 `src/components/frontend/src/time.ts` 的统一格式化和输入转换函数。原始 JSON 中的 ISO 时间戳只能在用户显式点击 `查看原始JSON` 后作为原始数据出现,默认结构化控件不得把 UTC/本地时区混入北京时间显示
frontend 默认可见的时间、日期、时钟、更新时间、心跳时间、Trace 时间、Gantt 时间轴刻度、导出文件日期和 `datetime-local` 输入值,只能在 Web 渲染层按 `config/frontend.yaml``displayTime` 配置显示。当前北京时间配置值写在 YAML 中;文档不得把具体时区、locale 或 label 复制成第二真相
frontend Bun server 启动时必须读取并校验 `config/frontend.yaml`,再把 `displayTime` 注入 root `data-config`。浏览器端统一复用 `src/components/frontend/src/time.ts` 的格式化和输入转换函数;这些 helper 只读取 `data-config.displayTime`,不得从浏览器本地时区、服务器系统时区、env、`config.json.project.timezone` 或硬编码常量回退。
上游服务、任务 trace 和后端 API 应保持统一机器时间事实(例如 ISO/UTC timestamp),不得把本地时区展示字符串作为默认结构化控件的时间来源。原始 JSON 中的 ISO 时间戳只能在用户显式点击 `查看原始JSON` 后作为原始数据出现;默认 Web 控件负责按唯一 YAML 配置渲染。
## Layout
+1
View File
@@ -3,6 +3,7 @@ WORKDIR /app/src/components/frontend
COPY src/components/frontend/package.json ./package.json
RUN bun install --production
COPY src/components/shared /app/src/components/shared
COPY config/frontend.yaml /app/config/frontend.yaml
COPY docs /app/docs
COPY src/components/frontend/src ./src
COPY src/components/frontend/public ./public
+3 -13
View File
@@ -1,5 +1,5 @@
import React from "react";
import { BEIJING_TIME_LABEL, fmtClock, fmtDate } from "./time";
import { displayTimeLabel, fmtClock, fmtDate } from "./time";
import { createRoot } from "react-dom/client";
import { BaiduNetdiskPage } from "./baidu-netdisk";
import { ClaudeQqPage } from "./claudeqq";
@@ -22,20 +22,10 @@ import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
import { NotificationProvider, useNotification } from "./notification-context";
import { NotificationPopup, NotificationBanner } from "./notification-popup";
import { readRootJsonAttribute } from "./runtime-config";
type AnyRecord = Record<string, any>;
function readRootJsonAttribute(name: string, fallback: any): any {
const raw = document.getElementById("root")?.getAttribute(name);
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as AnyRecord : fallback;
} catch {
return fallback;
}
}
const cfg: AnyRecord = readRootJsonAttribute("data-config", { apiBaseUrl: "/api", authUsername: "admin" });
const environmentIdentity: AnyRecord = cfg.environment && typeof cfg.environment === "object" ? cfg.environment : {};
const initialCodeQueueOverview = readRootJsonAttribute("data-codex-overview", null);
@@ -533,7 +523,7 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock,
{ key: "core", label: "核心", value: connection.text, tone: connection.ok ? "ok" : "fail", testId: "conn-text" },
...(Array.isArray(activeStatusItems) ? activeStatusItems : []),
{ key: "refresh", label: "刷新", value: lastRefresh ? fmtClock(lastRefresh) : "未刷新" },
{ key: "clock", label: BEIJING_TIME_LABEL, value: fmtClock(clock) },
{ key: "clock", label: displayTimeLabel(), value: fmtClock(clock) },
{ key: "user", label: "用户", value: session?.user?.username || "--", tone: "user" },
];
return h("header", { className: "topbar" },
+2 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { displayTimeZone, fmtClock, fmtDate } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { MarkdownBody } from "./markdown";
import { TraceView, codexTracePort } from "./trace";
@@ -2004,7 +2004,7 @@ function CodexStatsPanel({ stats, queueName: activeQueueName, onRaw }: AnyRecord
title: "统计曲线",
eyebrow: `Daily task stats / ${activeQueueName}`,
className: `codex-stats-panel ${statsState.state}`,
summary: h("span", null, `${statDateLabel(statsRange.startDate)} -> ${statDateLabel(statsRange.endDate)} · ${stats?.timezone || "Asia/Shanghai"} · ${statsStateLabel}`),
summary: h("span", null, `${statDateLabel(statsRange.startDate)} -> ${statDateLabel(statsRange.endDate)} · ${displayTimeZone()} · ${statsStateLabel}`),
actions: objectRecord(stats) ? h(RawButton, { title: "Code Queue Stats", data: stats, onOpen: onRaw, testId: "raw-codex-stats" }) : null,
},
h("div", { className: "codex-stats-hero", "data-testid": "codex-stats-panel", "data-stats-state": statsState.state, "data-stats-degraded": statsState.degraded === true ? "true" : "false" },
+54
View File
@@ -10,6 +10,7 @@ interface RuntimeConfig {
coreInternalUrl: string;
frontendPublicUrl: string;
providerIngressPublicUrl: string;
displayTime: DisplayTimeConfig;
authUsername: string;
authPassword: string;
providerToken: string | null;
@@ -39,6 +40,12 @@ interface RuntimeIdentity {
requestedCommit: string;
}
interface DisplayTimeConfig {
timeZone: string;
locale: string;
label: string;
}
interface SessionPayload {
username: string;
expiresAt: number;
@@ -88,6 +95,7 @@ const appBundle = await buildFrontendApp("app.tsx");
const clientConfig = JSON.stringify({
frontendPublicUrl: config.frontendPublicUrl,
providerIngressPublicUrl: config.providerIngressPublicUrl,
displayTime: config.displayTime,
authUsername: config.authUsername,
sessionTtlSeconds: config.sessionTtlSeconds,
apiBaseUrl: "/api",
@@ -178,6 +186,51 @@ function optionalFileEnv(name: string, fallbackPath: string | null = null): stri
return path === null ? null : optionalFile(path);
}
function requiredStringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value.trim();
}
function recordField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
const value = obj[key];
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path}.${key} must be an object`);
return value as Record<string, unknown>;
}
function validateTimeZone(value: string, path: string): void {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date(0));
} catch {
throw new Error(`${path} must be a valid IANA time zone`);
}
}
function validateLocale(value: string, path: string): void {
try {
new Intl.DateTimeFormat(value).format(new Date(0));
} catch {
throw new Error(`${path} must be a valid Intl locale`);
}
}
function readDisplayTimeConfig(): DisplayTimeConfig {
const configPath = join(import.meta.dir, "../../../..", "config", "frontend.yaml");
const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("config/frontend.yaml must be an object");
const root = parsed as Record<string, unknown>;
if (root.kind !== "UniDeskFrontendConfig") throw new Error("config/frontend.yaml.kind must be UniDeskFrontendConfig");
const displayTime = recordField(root, "displayTime", "config/frontend.yaml");
const result = {
timeZone: requiredStringField(displayTime, "timeZone", "config/frontend.yaml.displayTime"),
locale: requiredStringField(displayTime, "locale", "config/frontend.yaml.displayTime"),
label: requiredStringField(displayTime, "label", "config/frontend.yaml.displayTime"),
};
validateTimeZone(result.timeZone, "config/frontend.yaml.displayTime.timeZone");
validateLocale(result.locale, "config/frontend.yaml.displayTime.locale");
return result;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
@@ -203,6 +256,7 @@ function readConfig(): RuntimeConfig {
coreInternalUrl: requiredEnv("CORE_INTERNAL_URL"),
frontendPublicUrl: requiredEnv("FRONTEND_PUBLIC_URL"),
providerIngressPublicUrl: requiredEnv("PROVIDER_INGRESS_PUBLIC_URL"),
displayTime: readDisplayTimeConfig(),
authUsername: requiredEnv("AUTH_USERNAME"),
authPassword: requiredEnv("AUTH_PASSWORD"),
providerToken: optionalEnv("PROVIDER_TOKEN")
@@ -1,5 +1,5 @@
import React from "react";
import { beijingDateStamp, fmtClock, fmtDate } from "./time";
import { displayDateStamp, fmtClock, fmtDate } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { errorMessage, requestBlob, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -230,7 +230,7 @@ export function ProjectManagerPage({ microservices, onRaw, apiBaseUrl = "/api" }
const href = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = `project-manager-${beijingDateStamp()}.xlsx`;
anchor.download = `project-manager-${displayDateStamp()}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
@@ -0,0 +1,71 @@
type AnyRecord = Record<string, any>;
export interface DisplayTimeConfig {
timeZone: string;
locale: string;
label: string;
}
let cachedFrontendConfig: AnyRecord | null = null;
let cachedDisplayTimeConfig: DisplayTimeConfig | null = null;
export function readRootJsonAttribute(name: string, fallback: any): any {
const raw = document.getElementById("root")?.getAttribute(name);
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as AnyRecord : fallback;
} catch {
return fallback;
}
}
export function frontendConfig(): AnyRecord {
if (cachedFrontendConfig !== null) return cachedFrontendConfig;
const parsed = readRootJsonAttribute("data-config", null);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("frontend data-config is required");
}
cachedFrontendConfig = parsed as AnyRecord;
return cachedFrontendConfig;
}
function requiredStringField(obj: AnyRecord, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value.trim();
}
function validateTimeZone(timeZone: string): void {
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0));
} catch {
throw new Error(`frontend displayTime.timeZone must be a valid IANA time zone, got ${timeZone}`);
}
}
function validateLocale(locale: string): void {
try {
new Intl.DateTimeFormat(locale).format(new Date(0));
} catch {
throw new Error(`frontend displayTime.locale must be a valid Intl locale, got ${locale}`);
}
}
export function displayTimeConfig(): DisplayTimeConfig {
if (cachedDisplayTimeConfig !== null) return cachedDisplayTimeConfig;
const displayTime = frontendConfig().displayTime;
if (typeof displayTime !== "object" || displayTime === null || Array.isArray(displayTime)) {
throw new Error("frontend data-config.displayTime is required");
}
const record = displayTime as AnyRecord;
const config = {
timeZone: requiredStringField(record, "timeZone", "frontend data-config.displayTime"),
locale: requiredStringField(record, "locale", "frontend data-config.displayTime"),
label: requiredStringField(record, "label", "frontend data-config.displayTime"),
};
validateTimeZone(config.timeZone);
validateLocale(config.locale);
cachedDisplayTimeConfig = config;
return config;
}
+86 -30
View File
@@ -1,24 +1,15 @@
export const BEIJING_TIME_ZONE = "Asia/Shanghai";
export const BEIJING_TIME_LABEL = "北京时间";
import { displayTimeConfig, type DisplayTimeConfig } from "./runtime-config";
const BEIJING_UTC_OFFSET_HOURS = 8;
const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
timeZone: BEIJING_TIME_ZONE,
hour12: false,
};
const CLOCK_OPTIONS: Intl.DateTimeFormatOptions = {
timeZone: BEIJING_TIME_ZONE,
hour12: false,
};
const INPUT_PARTS_FORMATTER = new Intl.DateTimeFormat("en-CA", {
timeZone: BEIJING_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
const inputPartsFormatters = new Map<string, Intl.DateTimeFormat>();
const offsetPartsFormatters = new Map<string, Intl.DateTimeFormat>();
export function displayTimeZone(): string {
return displayTimeConfig().timeZone;
}
export function displayTimeLabel(): string {
return displayTimeConfig().label;
}
function coerceDate(value: any): Date | null {
if (value === null || value === undefined || value === "") return null;
@@ -26,10 +17,49 @@ function coerceDate(value: any): Date | null {
return Number.isNaN(date.getTime()) ? null : date;
}
function beijingInputParts(value: any): Record<string, string> | null {
function formatterKey(config: DisplayTimeConfig): string {
return `${config.locale}|${config.timeZone}`;
}
function inputPartsFormatter(config: DisplayTimeConfig): Intl.DateTimeFormat {
const key = formatterKey(config);
const existing = inputPartsFormatters.get(key);
if (existing) return existing;
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: config.timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
inputPartsFormatters.set(key, formatter);
return formatter;
}
function offsetPartsFormatter(config: DisplayTimeConfig): Intl.DateTimeFormat {
const key = formatterKey(config);
const existing = offsetPartsFormatters.get(key);
if (existing) return existing;
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: config.timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
offsetPartsFormatters.set(key, formatter);
return formatter;
}
function partsFor(value: any, formatter: Intl.DateTimeFormat): Record<string, string> | null {
const date = coerceDate(value);
if (!date) return null;
return INPUT_PARTS_FORMATTER.formatToParts(date).reduce((parts: Record<string, string>, part) => {
return formatter.formatToParts(date).reduce((parts: Record<string, string>, part) => {
if (part.type !== "literal") parts[part.type] = part.value;
return parts;
}, {});
@@ -37,40 +67,66 @@ function beijingInputParts(value: any): Record<string, string> | null {
export function fmtDate(value: any): string {
const date = coerceDate(value);
return date ? date.toLocaleString("zh-CN", DATE_TIME_OPTIONS) : "--";
const config = displayTimeConfig();
return date ? date.toLocaleString(config.locale, { timeZone: config.timeZone, hour12: false }) : "--";
}
export function fmtClock(value: any): string {
const date = coerceDate(value);
return date ? date.toLocaleTimeString("zh-CN", CLOCK_OPTIONS) : "--";
const config = displayTimeConfig();
return date ? date.toLocaleTimeString(config.locale, { timeZone: config.timeZone, hour12: false }) : "--";
}
export function fmtDateTimeLocalInput(value: any): string {
const parts = beijingInputParts(value);
const config = displayTimeConfig();
const parts = partsFor(value, inputPartsFormatter(config));
if (!parts) return "";
const hour = parts.hour === "24" ? "00" : parts.hour;
return `${parts.year}-${parts.month}-${parts.day}T${hour}:${parts.minute}`;
}
export function beijingDateStamp(value: any = new Date()): string {
const parts = beijingInputParts(value);
export function displayDateStamp(value: any = new Date()): string {
const config = displayTimeConfig();
const parts = partsFor(value, inputPartsFormatter(config));
if (!parts) return "";
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function localDateTimeInputToBeijingIso(value: string): string | null {
function partsUtcMs(parts: Record<string, string>): number {
const hour = parts.hour === "24" ? "00" : parts.hour;
return Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(hour),
Number(parts.minute),
Number(parts.second || "00"),
);
}
function timeZoneOffsetMs(date: Date, config: DisplayTimeConfig): number {
const parts = partsFor(date, offsetPartsFormatter(config));
return parts ? partsUtcMs(parts) - date.getTime() : 0;
}
export function localDateTimeInputToDisplayIso(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
if (!match) return null;
const [, year, month, day, hour, minute, second = "00"] = match;
const utcMs = Date.UTC(
const targetLocalAsUtcMs = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour) - BEIJING_UTC_OFFSET_HOURS,
Number(hour),
Number(minute),
Number(second),
);
const config = displayTimeConfig();
let utcMs = targetLocalAsUtcMs - timeZoneOffsetMs(new Date(targetLocalAsUtcMs), config);
for (let index = 0; index < 3; index += 1) {
utcMs = targetLocalAsUtcMs - timeZoneOffsetMs(new Date(utcMs), config);
}
const date = new Date(utcMs);
const normalized = fmtDateTimeLocalInput(date);
return Number.isNaN(date.getTime()) || normalized !== `${year}-${month}-${day}T${hour}:${minute}` ? null : date.toISOString();
+2 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { fmtClock, fmtDate, fmtDateTimeLocalInput, localDateTimeInputToBeijingIso } from "./time";
import { fmtClock, fmtDate, fmtDateTimeLocalInput, localDateTimeInputToDisplayIso } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -438,7 +438,7 @@ function TodoRow(props: AnyRecord): ReactNode {
todo.reminderAt ? h("span", { className: "todo-reminder" }, `提醒 ${fmtDate(todo.reminderAt)}`) : h("span", null, "无提醒"),
),
),
h("input", { className: "todo-reminder-input", type: "datetime-local", value: fmtDateTimeLocalInput(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localDateTimeInputToBeijingIso(event.target.value) }) }),
h("input", { className: "todo-reminder-input", type: "datetime-local", value: fmtDateTimeLocalInput(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localDateTimeInputToDisplayIso(event.target.value) }) }),
h("div", { className: "todo-row-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => beginEdit(todo) }, "编辑"),
h("button", { type: "button", className: "ghost-btn", onClick: () => addChild(todo.id) }, "子项"),
+2 -2
View File
@@ -1,4 +1,4 @@
import { BEIJING_TIME_LABEL, fmtDate } from "./time";
import { displayTimeLabel, fmtDate } from "./time";
type AnyRecord = Record<string, any>;
@@ -233,7 +233,7 @@ function fmtDateTime(value: string | undefined): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return `${fmtDate(date)} ${BEIJING_TIME_LABEL}`;
return `${fmtDate(date)} ${displayTimeLabel()}`;
}
export function describeUniDeskError(error: unknown, fallback = "操作失败"): UniDeskErrorView {