diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx
index c5ed263..6791145 100644
--- a/src/shared/ui/DataTable.jsx
+++ b/src/shared/ui/DataTable.jsx
@@ -4,6 +4,7 @@ import MenuList from "@mui/material/MenuList";
import Popover from "@mui/material/Popover";
import TextField from "@mui/material/TextField";
import { useEffect, useMemo, useState } from "react";
+import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
import { Button } from "@/shared/ui/Button.jsx";
const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
@@ -26,11 +27,13 @@ export function DataTable({
title,
total
}) {
+ const { timeZone } = useTimeZone();
const [columnWidths, setColumnWidths] = useState({});
const [filterMenu, setFilterMenu] = useState({ anchorEl: null, columnKey: "" });
const [filterQuery, setFilterQuery] = useState("");
const [resizeSession, setResizeSession] = useState(null);
const safeItems = Array.isArray(items) ? items : [];
+ const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]);
const preparedColumns = useMemo(
() =>
@@ -181,7 +184,7 @@ export function DataTable({
{preparedColumns.map((column) => (
- {column.render ? column.render(item, index, context) : displayValue(item[column.key])}
+ {column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
))}
diff --git a/src/shared/ui/DataTable.timezone.test.jsx b/src/shared/ui/DataTable.timezone.test.jsx
new file mode 100644
index 0000000..8af4fe2
--- /dev/null
+++ b/src/shared/ui/DataTable.timezone.test.jsx
@@ -0,0 +1,48 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, expect, test } from "vitest";
+import { TimeZoneProvider, useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
+import { formatMillis } from "@/shared/utils/time.js";
+import { DataTable } from "./DataTable.jsx";
+
+const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0);
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+
+test("rerenders table timestamp cells when display timezone changes", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("2026-05-10 03:21:00")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "UTC" }));
+
+ expect(screen.getByText("2026-05-09 19:21:00")).toBeInTheDocument();
+});
+
+function TimezoneTableFixture() {
+ const { setTimeZone } = useTimeZone();
+ const columns = [
+ {
+ key: "createdAtMs",
+ label: "创建时间",
+ render: (item) => formatMillis(item.createdAtMs)
+ }
+ ];
+
+ return (
+ <>
+
+
item.id} />
+ >
+ );
+}
diff --git a/src/shared/ui/PageLoadBoundary.jsx b/src/shared/ui/PageLoadBoundary.jsx
new file mode 100644
index 0000000..32f521b
--- /dev/null
+++ b/src/shared/ui/PageLoadBoundary.jsx
@@ -0,0 +1,80 @@
+import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useState } from "react";
+import { PageSkeleton } from "./PageSkeleton.jsx";
+
+const PageLoadContext = createContext(null);
+const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
+
+export function PageLoadBoundary({ children, pageKey }) {
+ const [loaders, setLoaders] = useState(() => new Map());
+ const [released, setReleased] = useState(false);
+
+ useEffect(() => {
+ setLoaders(new Map());
+ setReleased(false);
+ }, [pageKey]);
+
+ const reportLoading = useCallback((id, loading) => {
+ setLoaders((current) => {
+ if (loading === null) {
+ if (!current.has(id)) {
+ return current;
+ }
+ const next = new Map(current);
+ next.delete(id);
+ return next;
+ }
+
+ if (current.get(id) === loading) {
+ return current;
+ }
+
+ const next = new Map(current);
+ next.set(id, loading);
+ return next;
+ });
+ }, []);
+
+ const hasPendingLoader = useMemo(() => Array.from(loaders.values()).some(Boolean), [loaders]);
+ const showSkeleton = !released && hasPendingLoader;
+
+ useIsomorphicLayoutEffect(() => {
+ if (!released && loaders.size > 0 && !hasPendingLoader) {
+ setReleased(true);
+ }
+ }, [hasPendingLoader, loaders.size, released]);
+
+ const contextValue = useMemo(() => ({ reportLoading }), [reportLoading]);
+
+ return (
+
+
+ {showSkeleton ?
: null}
+
+ {children}
+
+
+
+ );
+}
+
+export function usePageLoadingStatus(loading, enabled = true) {
+ const context = useContext(PageLoadContext);
+ const id = useId();
+
+ useIsomorphicLayoutEffect(() => {
+ if (!context || !enabled) {
+ return undefined;
+ }
+
+ context.reportLoading(id, Boolean(loading));
+
+ return () => {
+ context.reportLoading(id, null);
+ };
+ }, [context, enabled, id, loading]);
+}
diff --git a/src/shared/ui/TimeText.jsx b/src/shared/ui/TimeText.jsx
new file mode 100644
index 0000000..5379df8
--- /dev/null
+++ b/src/shared/ui/TimeText.jsx
@@ -0,0 +1,12 @@
+import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
+
+export function TimeText({ className, component: Component = "span", value }) {
+ const { formatMillis, timeZoneLabel } = useTimeZone();
+ const text = formatMillis(value);
+
+ return (
+
+ {text}
+
+ );
+}
diff --git a/src/shared/utils/time.js b/src/shared/utils/time.js
index 3ea9cd3..94dc7d4 100644
--- a/src/shared/utils/time.js
+++ b/src/shared/utils/time.js
@@ -1,22 +1,131 @@
-export function formatTime(date = new Date()) {
- return date.toLocaleTimeString("zh-CN", {
- hour12: false,
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit"
- });
+export const DEFAULT_TIME_ZONE = "Asia/Shanghai";
+export const TIME_ZONE_STORAGE_KEY = "hyapp-admin.display-time-zone";
+export const TIME_ZONE_OPTIONS = [
+ { label: "中国时间", value: "Asia/Shanghai" },
+ { label: "UTC", value: "UTC" }
+];
+
+let activeTimeZone = DEFAULT_TIME_ZONE;
+
+const formatterCache = new Map();
+
+export function normalizeTimeZone(value) {
+ return TIME_ZONE_OPTIONS.some((option) => option.value === value) ? value : DEFAULT_TIME_ZONE;
}
-export function formatDateTime(value) {
- if (!value) {
- return "-";
- }
- return new Date(value).toLocaleString("zh-CN", { hour12: false });
+export function getActiveTimeZone() {
+ return activeTimeZone;
}
-export function formatMillis(value) {
- if (!value) {
- return "-";
- }
- return formatDateTime(Number(value));
+export function setActiveTimeZone(value) {
+ activeTimeZone = normalizeTimeZone(value);
+ return activeTimeZone;
+}
+
+export function getTimeZoneLabel(value) {
+ return TIME_ZONE_OPTIONS.find((option) => option.value === value)?.label || value || "-";
+}
+
+export function readStoredTimeZone(storage = globalThis.localStorage) {
+ try {
+ return normalizeTimeZone(storage?.getItem(TIME_ZONE_STORAGE_KEY));
+ } catch {
+ return DEFAULT_TIME_ZONE;
+ }
+}
+
+export function writeStoredTimeZone(value, storage = globalThis.localStorage) {
+ const normalized = normalizeTimeZone(value);
+ try {
+ storage?.setItem(TIME_ZONE_STORAGE_KEY, normalized);
+ } catch {
+ // 仅影响偏好持久化,不能阻断本次页面内切换。
+ }
+ return normalized;
+}
+
+export function toEpochMillis(value) {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+ if (value instanceof Date) {
+ return finitePositiveMillis(value.getTime());
+ }
+ if (typeof value === "number") {
+ return finitePositiveMillis(value);
+ }
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ const text = value.trim();
+ if (!text) {
+ return null;
+ }
+ if (/^\d+$/.test(text)) {
+ return finitePositiveMillis(Number(text));
+ }
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) {
+ return null;
+ }
+ return finitePositiveMillis(Date.parse(text));
+}
+
+export function formatTime(value = Date.now(), timeZone = activeTimeZone) {
+ const parts = dateTimeParts(value, timeZone);
+ return parts ? `${parts.hour}:${parts.minute}:${parts.second}` : "-";
+}
+
+export function formatDateTime(value, timeZone = activeTimeZone) {
+ const parts = dateTimeParts(value, timeZone);
+ return parts ? `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` : "-";
+}
+
+export function formatMillis(value, timeZone = activeTimeZone) {
+ return formatDateTime(value, timeZone);
+}
+
+function finitePositiveMillis(value) {
+ return Number.isFinite(value) && value > 0 ? value : null;
+}
+
+function dateTimeParts(value, timeZone) {
+ const epochMillis = toEpochMillis(value);
+ if (!epochMillis) {
+ return null;
+ }
+
+ const partMap = Object.fromEntries(
+ getFormatter(normalizeTimeZone(timeZone))
+ .formatToParts(epochMillis)
+ .filter((part) => part.type !== "literal")
+ .map((part) => [part.type, part.value])
+ );
+ return {
+ day: partMap.day,
+ hour: partMap.hour,
+ minute: partMap.minute,
+ month: partMap.month,
+ second: partMap.second,
+ year: partMap.year
+ };
+}
+
+function getFormatter(timeZone) {
+ if (!formatterCache.has(timeZone)) {
+ formatterCache.set(
+ timeZone,
+ new Intl.DateTimeFormat("en-CA", {
+ day: "2-digit",
+ hour: "2-digit",
+ hourCycle: "h23",
+ minute: "2-digit",
+ month: "2-digit",
+ second: "2-digit",
+ timeZone,
+ year: "numeric"
+ })
+ );
+ }
+ return formatterCache.get(timeZone);
}
diff --git a/src/shared/utils/time.test.js b/src/shared/utils/time.test.js
new file mode 100644
index 0000000..b227df2
--- /dev/null
+++ b/src/shared/utils/time.test.js
@@ -0,0 +1,21 @@
+import { describe, expect, test } from "vitest";
+import { formatMillis, normalizeTimeZone, toEpochMillis } from "./time.js";
+
+describe("time formatting", () => {
+ test("formats the same epoch milliseconds in UTC and China time", () => {
+ const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0);
+
+ expect(formatMillis(epochMillis, "UTC")).toBe("2026-05-09 19:21:00");
+ expect(formatMillis(epochMillis, "Asia/Shanghai")).toBe("2026-05-10 03:21:00");
+ });
+
+ test("keeps ambiguous local date strings out of table rendering", () => {
+ expect(toEpochMillis("2026-05-09 19:21:00")).toBeNull();
+ expect(formatMillis("2026-05-09T19:21:00Z", "Asia/Shanghai")).toBe("2026-05-10 03:21:00");
+ });
+
+ test("normalizes unsupported zones to the product default", () => {
+ expect(normalizeTimeZone("UTC")).toBe("UTC");
+ expect(normalizeTimeZone("America/Los_Angeles")).toBe("Asia/Shanghai");
+ });
+});
diff --git a/src/styles/layout.css b/src/styles/layout.css
index e9fa9b6..7c83f89 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -168,11 +168,17 @@
flex: 0 0 156px;
}
-.app-switch .MuiOutlinedInput-root {
+.timezone-switch {
+ flex: 0 0 124px;
+}
+
+.app-switch .MuiOutlinedInput-root,
+.timezone-switch .MuiOutlinedInput-root {
background: var(--bg-input);
}
-.app-switch .MuiSelect-select {
+.app-switch .MuiSelect-select,
+.timezone-switch .MuiSelect-select {
font-weight: 650;
}
@@ -734,6 +740,23 @@
animation: page-enter var(--motion-slow) var(--ease-emphasized) both;
}
+.page-load-boundary {
+ position: relative;
+ min-height: 0;
+}
+
+.page-load-boundary__content {
+ min-height: 0;
+}
+
+.page-load-boundary__content--hidden {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ visibility: hidden;
+ pointer-events: none;
+}
+
.page-skeleton {
display: grid;
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
diff --git a/src/styles/responsive.css b/src/styles/responsive.css
index 6816a0f..63be812 100644
--- a/src/styles/responsive.css
+++ b/src/styles/responsive.css
@@ -69,6 +69,10 @@
display: none;
}
+ .timezone-switch {
+ flex-basis: 112px;
+ }
+
.workspace {
padding: 16px;
}