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:
94
apps/web/src/components/DrawerForm.tsx
Normal file
94
apps/web/src/components/DrawerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/components/EntityName.tsx
Normal file
23
apps/web/src/components/EntityName.tsx
Normal 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>;
|
||||
}
|
||||
38
apps/web/src/components/FilterBar.tsx
Normal file
38
apps/web/src/components/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/PageContainer.tsx
Normal file
72
apps/web/src/components/PageContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
9
apps/web/src/utils/dayjs.ts
Normal file
9
apps/web/src/utils/dayjs.ts
Normal 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;
|
||||
16
apps/web/src/utils/format.ts
Normal file
16
apps/web/src/utils/format.ts
Normal 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}岁`;
|
||||
};
|
||||
Reference in New Issue
Block a user