diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index a6b60dcc..454f7f7d 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -346,7 +346,9 @@ lanes: - internal/dev-entrypoint/cloud-web-routes.mjs - tools/src/hwlab-cli/trace-renderer.ts env: - TZ: Asia/Shanghai + HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: Asia/Shanghai + HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: zh-CN + HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: 北京时间 observable: true hwlab-gateway: runtimeKind: bun-command diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index a4a92099..2e1bae16 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -125,12 +125,45 @@ function injectCloudWebRuntimeConfig(body) { } function runtimeConfigFromEnv() { - const config = {}; + const config = { displayTime: displayTimeConfigFromEnv() }; const workbench = workbenchRuntimeConfigFromEnv(); if (workbench) config.workbench = workbench; return config; } +function displayTimeConfigFromEnv() { + const timeZone = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE"); + const locale = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE"); + const label = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL"); + validateDisplayTimeZone(timeZone); + validateDisplayLocale(locale); + return { timeZone, locale, label }; +} + +function requiredRuntimeConfigEnv(name) { + const value = process.env[name]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`missing required Cloud Web runtime config env: ${name}`); + } + return value.trim(); +} + +function validateDisplayTimeZone(timeZone) { + try { + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0)); + } catch { + throw new Error(`invalid HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: ${timeZone}`); + } +} + +function validateDisplayLocale(locale) { + try { + new Intl.DateTimeFormat(locale).format(new Date(0)); + } catch { + throw new Error(`invalid HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: ${locale}`); + } +} + function workbenchRuntimeConfigFromEnv() { const traceTimeline = {}; const autoExpandRunning = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING); diff --git a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue index 000b7e67..be598297 100644 --- a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue +++ b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue @@ -6,6 +6,7 @@ import { computed, ref } from "vue"; import { useWorkbenchStore } from "@/stores/workbench"; import { useClipboard } from "@/composables/useClipboard"; import LoadingState from "@/components/common/LoadingState.vue"; +import { formatDisplayDateTime } from "@/config/runtime"; import { workbenchSessionPath } from "@/utils"; const workbench = useWorkbenchStore(); @@ -91,7 +92,7 @@ function formatSessionUpdatedTime(value: string | null | undefined): string { if (delta < 3_600_000) return `${Math.max(1, Math.round(delta / 60_000))} 分`; if (delta < 86_400_000) return `${Math.max(1, Math.round(delta / 3_600_000))} 时`; if (delta < 604_800_000) return `${Math.max(1, Math.round(delta / 86_400_000))} 天`; - return date.toLocaleString(undefined, { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }); + return formatDisplayDateTime(date, { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }); } diff --git a/web/hwlab-cloud-web/src/config/runtime.ts b/web/hwlab-cloud-web/src/config/runtime.ts index 92225466..5c177d1d 100644 --- a/web/hwlab-cloud-web/src/config/runtime.ts +++ b/web/hwlab-cloud-web/src/config/runtime.ts @@ -1,5 +1,55 @@ import type { WorkbenchTraceTimelinePolicy } from "@/components/workbench/trace-lifecycle"; +export interface DisplayTimeConfig { + timeZone: string; + locale: string; + label: string; +} + +export function displayTimeConfig(): DisplayTimeConfig { + const config = window.HWLAB_CLOUD_WEB_CONFIG?.displayTime; + const timeZone = requiredDisplayTimeField(config?.timeZone, "timeZone"); + const locale = requiredDisplayTimeField(config?.locale, "locale"); + const label = requiredDisplayTimeField(config?.label, "label"); + validateDisplayTimeZone(timeZone); + validateDisplayLocale(locale); + return { timeZone, locale, label }; +} + +export function displayTimeLabel(): string { + return displayTimeConfig().label; +} + +export function formatDisplayDateTime(value: string | Date, options: Intl.DateTimeFormatOptions = {}): string { + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) return String(value); + const config = displayTimeConfig(); + return new Intl.DateTimeFormat(config.locale, { hour12: false, ...options, timeZone: config.timeZone }).format(date); +} + +function requiredDisplayTimeField(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`HWLAB Cloud Web displayTime.${field} is required`); + } + return value.trim(); +} + +function validateDisplayTimeZone(timeZone: string): void { + try { + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0)); + } catch { + throw new Error(`HWLAB Cloud Web displayTime.timeZone is invalid: ${timeZone}`); + } +} + +function validateDisplayLocale(locale: string): void { + try { + new Intl.DateTimeFormat(locale).format(new Date(0)); + } catch { + throw new Error(`HWLAB Cloud Web displayTime.locale is invalid: ${locale}`); + } +} + export function workbenchTraceTimelinePolicy(): WorkbenchTraceTimelinePolicy { const config = window.HWLAB_CLOUD_WEB_CONFIG?.workbench?.traceTimeline; return { diff --git a/web/hwlab-cloud-web/src/stores/workbench-live.ts b/web/hwlab-cloud-web/src/stores/workbench-live.ts index 42e379eb..bf9b4f70 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-live.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-live.ts @@ -1,3 +1,4 @@ +import { displayTimeLabel, formatDisplayDateTime } from "@/config/runtime"; import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse, LiveProbePayload, LiveSurface, Tone } from "@/types"; import { asRecord, firstNonEmptyString, statusTone } from "@/utils"; @@ -148,7 +149,7 @@ export function formatTimestamp(value: unknown): string { if (!text) return "未观测"; const date = new Date(text); if (!Number.isFinite(date.getTime())) return text; - return date.toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" }); + return formatDisplayDateTime(date, { hour12: false }); } export function shortToken(value: unknown, size = 12): string | null { @@ -224,7 +225,7 @@ function rowFromLiveHealth(payload: LiveProbePayload | null): BuildRow { } function latestBuildText(row: BuildRow): string { - const time = row.builtAt ? `${formatTimestamp(row.builtAt)} 北京时间` : "构建时间不可用"; + const time = row.builtAt ? `${formatTimestamp(row.builtAt)} ${displayTimeLabel()}` : "构建时间不可用"; return `最新镜像构建时间:${time} · ${row.label} · tag ${row.tag ?? "unknown"} · commit ${row.commitShort ?? "unknown"} · env ${row.envImageTag ?? row.envCommitShort ?? "unknown"} · revision ${row.revisionShort ?? "unknown"}`; } diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index aa5ef323..ce786367 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -2,6 +2,11 @@ declare global { interface Window { HWLAB_CLOUD_WEB_CONFIG?: { auth?: { mode?: "auto" | "manual"; username?: string; password?: string }; + displayTime?: { + timeZone?: string; + locale?: string; + label?: string; + }; workbench?: { traceTimeline?: { autoExpandRunning?: boolean;