From 9349f801a64ffb3885bb2219465b595f5ed7bc92 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 05:00:35 +0000 Subject: [PATCH] fix: drive frontend timezone from yaml config --- config/frontend.yaml | 8 ++ docs/reference/arch.md | 6 +- docs/reference/frontend.md | 6 +- src/components/frontend/Dockerfile | 1 + src/components/frontend/src/app.tsx | 16 +-- src/components/frontend/src/code-queue.tsx | 4 +- src/components/frontend/src/index.ts | 54 ++++++++ .../frontend/src/project-manager.tsx | 4 +- src/components/frontend/src/runtime-config.ts | 71 +++++++++++ src/components/frontend/src/time.ts | 116 +++++++++++++----- src/components/frontend/src/todo-note.tsx | 4 +- src/components/frontend/src/unidesk-error.ts | 4 +- 12 files changed, 239 insertions(+), 55 deletions(-) create mode 100644 config/frontend.yaml create mode 100644 src/components/frontend/src/runtime-config.ts diff --git a/config/frontend.yaml b/config/frontend.yaml new file mode 100644 index 00000000..8fef5eda --- /dev/null +++ b/config/frontend.yaml @@ -0,0 +1,8 @@ +version: 1 +kind: UniDeskFrontendConfig +metadata: + name: unidesk-frontend +displayTime: + timeZone: Asia/Shanghai + locale: zh-CN + label: 北京时间 diff --git a/docs/reference/arch.md b/docs/reference/arch.md index f7517b64..ceddb6d5 100644 --- a/docs/reference/arch.md +++ b/docs/reference/arch.md @@ -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 diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 18f9b8a6..8b433d93 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -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 diff --git a/src/components/frontend/Dockerfile b/src/components/frontend/Dockerfile index 910d4608..5ae8a524 100644 --- a/src/components/frontend/Dockerfile +++ b/src/components/frontend/Dockerfile @@ -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 diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 8c2b1d9d..bb62d3c0 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -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; -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" }, diff --git a/src/components/frontend/src/code-queue.tsx b/src/components/frontend/src/code-queue.tsx index 3f6bf50f..1387b363 100644 --- a/src/components/frontend/src/code-queue.tsx +++ b/src/components/frontend/src/code-queue.tsx @@ -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" }, diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index 538a0654..bcad3675 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -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, 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, key: string, path: string): Record { + 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; +} + +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; + 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") diff --git a/src/components/frontend/src/project-manager.tsx b/src/components/frontend/src/project-manager.tsx index 42823c8e..a2418ec9 100644 --- a/src/components/frontend/src/project-manager.tsx +++ b/src/components/frontend/src/project-manager.tsx @@ -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(); diff --git a/src/components/frontend/src/runtime-config.ts b/src/components/frontend/src/runtime-config.ts new file mode 100644 index 00000000..bbe9e0f9 --- /dev/null +++ b/src/components/frontend/src/runtime-config.ts @@ -0,0 +1,71 @@ +type AnyRecord = Record; + +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; +} diff --git a/src/components/frontend/src/time.ts b/src/components/frontend/src/time.ts index 61284beb..18872cb4 100644 --- a/src/components/frontend/src/time.ts +++ b/src/components/frontend/src/time.ts @@ -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(); +const offsetPartsFormatters = new Map(); + +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 | 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 | null { const date = coerceDate(value); if (!date) return null; - return INPUT_PARTS_FORMATTER.formatToParts(date).reduce((parts: Record, part) => { + return formatter.formatToParts(date).reduce((parts: Record, part) => { if (part.type !== "literal") parts[part.type] = part.value; return parts; }, {}); @@ -37,40 +67,66 @@ function beijingInputParts(value: any): Record | 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): 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(); diff --git a/src/components/frontend/src/todo-note.tsx b/src/components/frontend/src/todo-note.tsx index 13e54a59..22a2d5c9 100644 --- a/src/components/frontend/src/todo-note.tsx +++ b/src/components/frontend/src/todo-note.tsx @@ -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) }, "子项"), diff --git a/src/components/frontend/src/unidesk-error.ts b/src/components/frontend/src/unidesk-error.ts index f41f6871..835b7175 100644 --- a/src/components/frontend/src/unidesk-error.ts +++ b/src/components/frontend/src/unidesk-error.ts @@ -1,4 +1,4 @@ -import { BEIJING_TIME_LABEL, fmtDate } from "./time"; +import { displayTimeLabel, fmtDate } from "./time"; type AnyRecord = Record; @@ -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 {