feat(web): 提取共享基础组件 — dayjs/format/EntityName/FilterBar/PageContainer/DrawerForm

- utils/dayjs.ts: 集中初始化 relativeTime 插件 + zh-cn locale
- utils/format.ts: formatDate/formatDateTime/formatRelative/calcAge
- components/EntityName.tsx: UUID→姓名兜底显示
- components/FilterBar.tsx: 统一筛选栏容器
- components/PageContainer.tsx: 统一页面容器(标题+筛选+表格+暗色模式)
- components/DrawerForm.tsx: 抽屉式表单容器(分组+双列网格)
- AlertList.tsx: 迁移到集中 dayjs 导入
This commit is contained in:
iven
2026-04-28 01:45:48 +08:00
parent 16a776c213
commit 5b47f13ecf
7 changed files with 253 additions and 6 deletions

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { Drawer, Form, Typography, Divider, Button, Space } from 'antd';
import { useThemeMode } from '../hooks/useThemeMode';
export interface FormSection {
title: string;
fields: React.ReactNode;
defaultCollapsed?: boolean;
}
interface DrawerFormProps {
title: string;
open: boolean;
onClose: () => void;
onSubmit: (values: Record<string, unknown>) => Promise<void>;
initialValues?: Record<string, unknown>;
loading?: boolean;
width?: number | string;
sections?: FormSection[];
children?: React.ReactNode;
columns?: 1 | 2;
}
export function DrawerForm({
title,
open,
onClose,
onSubmit,
initialValues,
loading,
width = 640,
sections,
children,
columns = 2,
}: DrawerFormProps) {
const [form] = Form.useForm();
const isDark = useThemeMode();
React.useEffect(() => {
if (open) {
form.resetFields();
if (initialValues) {
form.setFieldsValue(initialValues);
}
}
}, [open, initialValues, form]);
const handleSubmit = async () => {
const values = await form.validateFields();
await onSubmit(values);
};
const gridStyle: React.CSSProperties =
columns === 2
? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }
: {};
return (
<Drawer
title={title}
open={open}
onClose={onClose}
width={width}
styles={{
body: { background: isDark ? '#141414' : undefined },
}}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical" initialValues={initialValues}>
{sections
? sections.map((s, i) => (
<div key={i}>
{i > 0 && <Divider style={{ margin: '16px 0' }} />}
<Typography.Text
strong
style={{ fontSize: 14, marginBottom: 12, display: 'block' }}
>
{s.title}
</Typography.Text>
<div style={gridStyle}>{s.fields}</div>
</div>
))
: children && <div style={gridStyle}>{children}</div>}
</Form>
</Drawer>
);
}

View File

@@ -0,0 +1,23 @@
import { Tooltip, Typography } from 'antd';
interface EntityNameProps {
name?: string | null;
id?: string;
fallbackLabel?: string;
}
export function EntityName({ name, id, fallbackLabel = '未知' }: EntityNameProps) {
if (name) return <span>{name}</span>;
if (id) {
return (
<Tooltip title={`ID: ${id.slice(0, 8)}...`}>
<Typography.Text type="secondary" style={{ cursor: 'help' }}>
{fallbackLabel}
</Typography.Text>
</Tooltip>
);
}
return <Typography.Text type="secondary">{fallbackLabel}</Typography.Text>;
}

View File

@@ -0,0 +1,38 @@
import { Button, Flex, Space } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { useThemeMode } from '../hooks/useThemeMode';
interface FilterBarProps {
children: React.ReactNode;
onReset?: () => void;
extra?: React.ReactNode;
}
export function FilterBar({ children, onReset, extra }: FilterBarProps) {
const isDark = useThemeMode();
return (
<Flex
justify="space-between"
align="center"
style={{
padding: '12px 16px',
background: isDark ? '#1f1f1f' : '#fafafa',
borderRadius: 8,
marginBottom: 16,
}}
>
<Space wrap size="middle">
{children}
</Space>
<Space>
{onReset && (
<Button icon={<ReloadOutlined />} onClick={onReset}>
</Button>
)}
{extra}
</Space>
</Flex>
);
}

View File

@@ -0,0 +1,72 @@
import { Card, Flex, Space, Typography, Button } from 'antd';
import { useThemeMode } from '../hooks/useThemeMode';
import { FilterBar } from './FilterBar';
interface PageContainerProps {
title: string;
subtitle?: string;
filters?: React.ReactNode;
onResetFilters?: () => void;
filterExtra?: React.ReactNode;
actions?: React.ReactNode;
batchActions?: React.ReactNode;
selectedCount?: number;
onClearSelection?: () => void;
children: React.ReactNode;
loading?: boolean;
}
export function PageContainer({
title,
subtitle,
filters,
onResetFilters,
filterExtra,
actions,
batchActions,
selectedCount,
onClearSelection,
children,
loading,
}: PageContainerProps) {
const isDark = useThemeMode();
return (
<div style={{ padding: 24 }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{title}
</Typography.Title>
{subtitle && (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{subtitle}
</Typography.Text>
)}
</div>
<Space>
{selectedCount ? batchActions : actions}
{selectedCount ? (
<Button size="small" onClick={onClearSelection}>
({selectedCount})
</Button>
) : null}
</Space>
</Flex>
{filters && (
<FilterBar onReset={onResetFilters} extra={filterExtra}>
{filters}
</FilterBar>
)}
<Card
styles={{ body: { padding: 0 } }}
style={{ background: isDark ? '#141414' : '#fff' }}
loading={loading}
>
{children}
</Card>
</div>
);
}

View File

@@ -10,9 +10,7 @@ import {
} from 'antd';
import { CheckOutlined, StopOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
import { dayjs } from '../../utils/dayjs';
import {
listAlerts,
acknowledgeAlert,
@@ -22,9 +20,6 @@ import {
import { AuthButton } from '../../components/AuthButton';
import { useThemeMode } from '../../hooks/useThemeMode';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
// --- 常量映射 ---
const STATUS_OPTIONS = [

View File

@@ -0,0 +1,9 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
export { dayjs };
export default dayjs;

View File

@@ -0,0 +1,16 @@
import { dayjs } from './dayjs';
export const formatDate = (v: string | null | undefined): string =>
v ? dayjs(v).format('YYYY-MM-DD') : '--';
export const formatDateTime = (v: string | null | undefined): string =>
v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '--';
export const formatRelative = (v: string | null | undefined): string =>
v ? dayjs(v).fromNow() : '--';
export const calcAge = (birthDate: string | null | undefined): string => {
if (!birthDate) return '--';
const age = dayjs().diff(dayjs(birthDate), 'year');
return `${age}`;
};