Merge pull request #510 from pikasTech/fix/509-web-timezone
fix: drive frontend timezone from YAML config
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
version: 1
|
||||
kind: UniDeskFrontendConfig
|
||||
metadata:
|
||||
name: unidesk-frontend
|
||||
displayTime:
|
||||
timeZone: Asia/Shanghai
|
||||
locale: zh-CN
|
||||
label: 北京时间
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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