diff --git a/apps/web/src/components/DrawerForm.tsx b/apps/web/src/components/DrawerForm.tsx new file mode 100644 index 0000000..cb51e01 --- /dev/null +++ b/apps/web/src/components/DrawerForm.tsx @@ -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) => Promise; + initialValues?: Record; + 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 ( + + + + + } + > +
+ {sections + ? sections.map((s, i) => ( +
+ {i > 0 && } + + {s.title} + +
{s.fields}
+
+ )) + : children &&
{children}
} +
+
+ ); +} diff --git a/apps/web/src/components/EntityName.tsx b/apps/web/src/components/EntityName.tsx new file mode 100644 index 0000000..0e9afeb --- /dev/null +++ b/apps/web/src/components/EntityName.tsx @@ -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 {name}; + + if (id) { + return ( + + + {fallbackLabel} + + + ); + } + + return {fallbackLabel}; +} diff --git a/apps/web/src/components/FilterBar.tsx b/apps/web/src/components/FilterBar.tsx new file mode 100644 index 0000000..93e3ade --- /dev/null +++ b/apps/web/src/components/FilterBar.tsx @@ -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 ( + + + {children} + + + {onReset && ( + + )} + {extra} + + + ); +} diff --git a/apps/web/src/components/PageContainer.tsx b/apps/web/src/components/PageContainer.tsx new file mode 100644 index 0000000..bf85f25 --- /dev/null +++ b/apps/web/src/components/PageContainer.tsx @@ -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 ( +
+ +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+ + {selectedCount ? batchActions : actions} + {selectedCount ? ( + + ) : null} + +
+ + {filters && ( + + {filters} + + )} + + + {children} + +
+ ); +} diff --git a/apps/web/src/pages/health/AlertList.tsx b/apps/web/src/pages/health/AlertList.tsx index 1d56555..0e7e00d 100644 --- a/apps/web/src/pages/health/AlertList.tsx +++ b/apps/web/src/pages/health/AlertList.tsx @@ -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 = [ diff --git a/apps/web/src/utils/dayjs.ts b/apps/web/src/utils/dayjs.ts new file mode 100644 index 0000000..6eaa785 --- /dev/null +++ b/apps/web/src/utils/dayjs.ts @@ -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; diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts new file mode 100644 index 0000000..64ab2a1 --- /dev/null +++ b/apps/web/src/utils/format.ts @@ -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}岁`; +};