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