feat: 初始提交
This commit is contained in:
86
src/web/app.tsx
Normal file
86
src/web/app.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { Layout, RadioGroup } from "tdesign-react";
|
||||
|
||||
import type { MetaResponse } from "../shared/api";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||
import { MENU_ITEMS } from "./menu";
|
||||
import { AppRoutes } from "./routes";
|
||||
|
||||
const { Aside, Content, Header } = Layout;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ label: "系统", value: "system" },
|
||||
{ label: "明亮", value: "light" },
|
||||
{ label: "黑暗", value: "dark" },
|
||||
] as const;
|
||||
|
||||
export function App() {
|
||||
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
|
||||
const location = useLocation();
|
||||
const { data: meta } = useQuery({
|
||||
queryFn: fetchMeta,
|
||||
queryKey: ["meta"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = APP.title;
|
||||
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (value: ThemePreference) => {
|
||||
setThemePreference(value);
|
||||
};
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const pageTitle = currentItem?.label ?? APP.title;
|
||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||
|
||||
return (
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<span className="app-brand-group">
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||
</span>
|
||||
<span className="app-page-title">{pageTitle}</span>
|
||||
</div>
|
||||
<div className="app-header-right">
|
||||
<RadioGroup
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
theme="button"
|
||||
value={themePreference}
|
||||
variant="default-filled"
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Aside className="app-sidebar" width={collapsed ? "64px" : "232px"}>
|
||||
<Sidebar collapsed={collapsed} onToggleCollapsed={toggleCollapsed} />
|
||||
</Aside>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<AppRoutes />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchMeta(): Promise<MetaResponse> {
|
||||
const response = await fetch("/api/meta");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<MetaResponse>;
|
||||
}
|
||||
38
src/web/components/ErrorBoundary.tsx
Normal file
38
src/web/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Space } from "tdesign-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
override state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("渲染错误:", error, info.componentStack);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">
|
||||
<Alert message="页面渲染出现异常,请刷新重试" theme="error" title="页面出错" />
|
||||
<Button onClick={() => window.location.reload()} theme="primary">
|
||||
刷新页面
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
53
src/web/components/Sidebar/index.tsx
Normal file
53
src/web/components/Sidebar/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
||||
import { Button, Menu } from "tdesign-react";
|
||||
|
||||
import { MENU_ITEMS } from "../../menu";
|
||||
|
||||
const { MenuItem } = Menu;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggleCollapsed }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const activeValue = currentItem?.value ?? "";
|
||||
|
||||
const handleMenuChange = (value: number | string) => {
|
||||
const item = MENU_ITEMS.find((item) => item.value === value);
|
||||
if (item) {
|
||||
void navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className="app-sidebar-menu"
|
||||
collapsed={collapsed}
|
||||
onChange={handleMenuChange}
|
||||
operations={
|
||||
<Button
|
||||
className="app-sidebar-collapse-btn"
|
||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
onClick={onToggleCollapsed}
|
||||
shape="square"
|
||||
variant="text"
|
||||
/>
|
||||
}
|
||||
value={activeValue}
|
||||
width={collapsed ? "64px" : "232px"}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<MenuItem icon={item.icon} key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
1
src/web/css.d.ts
vendored
Normal file
1
src/web/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar.collapsed";
|
||||
|
||||
export function applyInitialSidebarCollapsed() {
|
||||
const collapsed = readSidebarCollapsed();
|
||||
applySidebarCollapsed(collapsed);
|
||||
}
|
||||
|
||||
export function applySidebarCollapsed(collapsed: boolean, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("data-sidebar-collapsed", String(collapsed));
|
||||
}
|
||||
|
||||
export function parseSidebarCollapsed(value: unknown): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function readSidebarCollapsed(storage: Storage = window.localStorage): boolean {
|
||||
try {
|
||||
return parseSidebarCollapsed(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsedState] = useState<boolean>(() => readSidebarCollapsed());
|
||||
|
||||
useEffect(() => {
|
||||
applySidebarCollapsed(collapsed);
|
||||
}, [collapsed]);
|
||||
|
||||
const setCollapsed = (nextCollapsed: boolean) => {
|
||||
setCollapsedState(nextCollapsed);
|
||||
writeSidebarCollapsed(nextCollapsed);
|
||||
};
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
return { collapsed, setCollapsed, toggleCollapsed };
|
||||
}
|
||||
|
||||
export function writeSidebarCollapsed(collapsed: boolean, storage: Storage = window.localStorage) {
|
||||
try {
|
||||
storage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态
|
||||
}
|
||||
}
|
||||
73
src/web/hooks/use-theme-preference.ts
Normal file
73
src/web/hooks/use-theme-preference.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type EffectiveTheme = "dark" | "light";
|
||||
export type ThemePreference = "dark" | "light" | "system";
|
||||
|
||||
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
export function applyInitialThemePreference() {
|
||||
applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark()));
|
||||
}
|
||||
|
||||
export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("theme-mode", theme);
|
||||
}
|
||||
|
||||
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||
try {
|
||||
return matchMedia(THEME_MEDIA_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseThemePreference(value: unknown): ThemePreference {
|
||||
return value === "dark" || value === "light" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
export function readThemePreference(storage: Storage = window.localStorage): ThemePreference {
|
||||
try {
|
||||
return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY));
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
||||
if (preference === "dark" || preference === "light") return preference;
|
||||
return systemPrefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function useThemePreference() {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
|
||||
mediaQueryList.addEventListener("change", handleChange);
|
||||
return () => mediaQueryList.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const setPreference = (nextPreference: ThemePreference) => {
|
||||
setPreferenceState(nextPreference);
|
||||
writeThemePreference(nextPreference);
|
||||
};
|
||||
|
||||
return { effectiveTheme, preference, setPreference };
|
||||
}
|
||||
|
||||
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
||||
try {
|
||||
storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
|
||||
}
|
||||
}
|
||||
13
src/web/index.html
Normal file
13
src/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="" />
|
||||
<title>App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
46
src/web/main.tsx
Normal file
46
src/web/main.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
|
||||
import { App } from "./app";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { applyInitialSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
staleTime: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("找不到前端挂载节点 #root");
|
||||
}
|
||||
|
||||
applyInitialThemePreference();
|
||||
applyInitialSidebarCollapsed();
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
18
src/web/menu.tsx
Normal file
18
src/web/menu.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { MenuValue } from "tdesign-react";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { DashboardIcon, SettingIcon, UserIcon } from "tdesign-icons-react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
path: string;
|
||||
value: MenuValue;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(UserIcon), label: "用户管理", path: "/users", value: "users" },
|
||||
{ icon: createElement(SettingIcon), label: "系统设置", path: "/settings", value: "settings" },
|
||||
] as const;
|
||||
22
src/web/pages/404/index.tsx
Normal file
22
src/web/pages/404/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { ErrorCircleIcon } from "tdesign-icons-react";
|
||||
import { Button, Space } from "tdesign-react";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoHome = () => {
|
||||
void navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<Space align="center" className="not-found-page" direction="vertical" size="large">
|
||||
<ErrorCircleIcon className="not-found-icon" size="64px" />
|
||||
<h1>404</h1>
|
||||
<p>您访问的页面不存在</p>
|
||||
<Button onClick={handleGoHome} theme="primary">
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
29
src/web/pages/dashboard/index.tsx
Normal file
29
src/web/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { MetaResponse } from "../../../shared/api";
|
||||
|
||||
import { APP } from "../../../shared/app";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { data: meta } = useQuery({
|
||||
queryFn: fetchMeta,
|
||||
queryKey: ["meta"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>欢迎使用 {APP.title}</h2>
|
||||
<p>在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):</p>
|
||||
{meta && <pre className="meta-response">{JSON.stringify(meta, null, 2)}</pre>}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchMeta(): Promise<MetaResponse> {
|
||||
const response = await fetch("/api/meta");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<MetaResponse>;
|
||||
}
|
||||
12
src/web/pages/settings/index.tsx
Normal file
12
src/web/pages/settings/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>系统设置</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
12
src/web/pages/users/index.tsx
Normal file
12
src/web/pages/users/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function UsersPage() {
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>用户管理</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
23
src/web/routes.tsx
Normal file
23
src/web/routes.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Route, Routes } from "react-router";
|
||||
|
||||
import { NotFoundPage } from "./pages/404";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { UsersPage } from "./pages/users";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
115
src/web/styles.css
Normal file
115
src/web/styles.css
Normal file
@@ -0,0 +1,115 @@
|
||||
:root {
|
||||
--td-brand-color: var(--td-brand-color-7);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--td-comp-paddingLR-l);
|
||||
background: var(--td-bg-color-container);
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-header-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-l);
|
||||
}
|
||||
|
||||
.app-header-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.app-brand-group {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: var(--td-font-size-body-small);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-sidebar-collapse-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.app-page-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background: var(--td-bg-color-container);
|
||||
border-right: 1px solid var(--td-component-border);
|
||||
height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar-menu {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
box-sizing: border-box;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.meta-response {
|
||||
background: var(--td-bg-color-component);
|
||||
border-radius: var(--td-radius-default);
|
||||
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
color: var(--td-text-color-primary);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
|
||||
.full-width-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.not-found-icon {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
46
src/web/utils/time.ts
Normal file
46
src/web/utils/time.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function formatCountdown(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||
}
|
||||
|
||||
export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
|
||||
if (ms === null) return { suffix: "", value: 0 };
|
||||
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };
|
||||
if (ms < 3600000) return { suffix: "分钟", value: roundToOne(ms / 60000) };
|
||||
return { suffix: "小时", value: roundToOne(ms / 3600000) };
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestamp: null | string, now = new Date()): string {
|
||||
if (!timestamp) return "尚无检查数据";
|
||||
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return "尚无检查数据";
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((now.getTime() - time) / 1000));
|
||||
if (diffSeconds < 60) return `${diffSeconds}秒前`;
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}小时前`;
|
||||
|
||||
return `${Math.floor(diffHours / 24)}天前`;
|
||||
}
|
||||
|
||||
export function isOlderThan(timestamp: null | string, ageMs: number, now = new Date()): boolean {
|
||||
if (!timestamp) return false;
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return false;
|
||||
return now.getTime() - time > ageMs;
|
||||
}
|
||||
|
||||
export function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
function roundToOne(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
Reference in New Issue
Block a user