fix: drive frontend timezone from yaml config
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) }, "子项"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user