fix: drive frontend timezone from yaml config

This commit is contained in:
Codex
2026-06-20 05:00:35 +00:00
parent 6a88f38ca9
commit 9349f801a6
12 changed files with 239 additions and 55 deletions
+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 {