diff --git a/apps/web/src/hooks/useCrudDrawer.ts b/apps/web/src/hooks/useCrudDrawer.ts new file mode 100644 index 0000000..ff80e75 --- /dev/null +++ b/apps/web/src/hooks/useCrudDrawer.ts @@ -0,0 +1,74 @@ +import { useState, useCallback } from 'react'; +import { useApiRequest } from './useApiRequest'; + +export interface UseCrudDrawerOptions { + getId: (record: T) => string; + onCreate: (values: Record) => Promise; + onUpdate: (id: string, values: Record & { version: number }) => Promise; + onSuccess?: () => void; +} + +export interface UseCrudDrawerReturn { + open: boolean; + editingRecord: T | null; + initialValues: Record | undefined; + openCreate: (defaults?: Record) => void; + openEdit: (record: T, fieldMap?: (record: T) => Record) => void; + close: () => void; + handleSubmit: (values: Record) => Promise; + loading: boolean; +} + +export function useCrudDrawer( + options: UseCrudDrawerOptions, +): UseCrudDrawerReturn { + const [open, setOpen] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [initialValues, setInitialValues] = useState | undefined>(undefined); + const { execute, loading } = useApiRequest(); + + const openCreate = useCallback((defaults?: Record) => { + setEditingRecord(null); + setInitialValues(defaults); + setOpen(true); + }, []); + + const openEdit = useCallback((record: T, fieldMap?: (record: T) => Record) => { + setEditingRecord(record); + setInitialValues(fieldMap ? fieldMap(record) : (record as unknown as Record)); + setOpen(true); + }, []); + + const close = useCallback(() => { + setOpen(false); + setEditingRecord(null); + setInitialValues(undefined); + }, []); + + const handleSubmit = useCallback( + async (values: Record) => { + if (editingRecord) { + await execute( + () => options.onUpdate(options.getId(editingRecord), { ...values, version: (editingRecord as unknown as { version: number }).version }), + '更新成功', + ); + } else { + await execute(() => options.onCreate(values), '创建成功'); + } + close(); + options.onSuccess?.(); + }, + [editingRecord, options, close, execute], + ); + + return { + open, + editingRecord, + initialValues, + openCreate, + openEdit, + close, + handleSubmit, + loading, + }; +} diff --git a/apps/web/src/hooks/useListData.ts b/apps/web/src/hooks/useListData.ts new file mode 100644 index 0000000..4b08604 --- /dev/null +++ b/apps/web/src/hooks/useListData.ts @@ -0,0 +1,33 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; + +export interface UseListDataReturn { + data: T[]; + loading: boolean; + refresh: () => Promise; +} + +export function useListData(fetchFn: () => Promise, autoFetch = true): UseListDataReturn { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const fetchFnRef = useRef(fetchFn); + fetchFnRef.current = fetchFn; + + const refresh = useCallback(async () => { + setLoading(true); + try { + const result = await fetchFnRef.current(); + setData(result); + } catch { + setData([]); + } + setLoading(false); + }, []); + + useEffect(() => { + if (autoFetch) { + refresh(); + } + }, [refresh, autoFetch]); + + return { data, loading, refresh }; +} diff --git a/apps/web/src/pages/Organizations.tsx b/apps/web/src/pages/Organizations.tsx index ace8a91..a0f6155 100644 --- a/apps/web/src/pages/Organizations.tsx +++ b/apps/web/src/pages/Organizations.tsx @@ -1,15 +1,13 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { Tree, Button, Space, - Modal, Form, Input, InputNumber, Table, Popconfirm, - message, Empty, Tag, } from 'antd'; @@ -21,7 +19,9 @@ import { } from '@ant-design/icons'; import type { DataNode } from 'antd/es/tree'; import { useThemeMode } from '../hooks/useThemeMode'; -import { handleApiError } from '../api/client'; +import { DrawerForm } from '../components/DrawerForm'; +import { useCrudDrawer } from '../hooks/useCrudDrawer'; +import { useApiRequest } from '../hooks/useApiRequest'; import { listOrgTree, createOrg, @@ -40,6 +40,7 @@ import { export default function Organizations() { const isDark = useThemeMode(); + const { execute } = useApiRequest(); const cardStyle = { background: isDark ? '#111827' : '#FFFFFF', @@ -50,7 +51,6 @@ export default function Organizations() { // --- Org tree state --- const [orgTree, setOrgTree] = useState([]); const [selectedOrg, setSelectedOrg] = useState(null); - const [, setLoading] = useState(false); // --- Department tree state --- const [deptTree, setDeptTree] = useState([]); @@ -59,41 +59,44 @@ export default function Organizations() { // --- Position list state --- const [positions, setPositions] = useState([]); - // --- Modal state --- - const [orgModalOpen, setOrgModalOpen] = useState(false); - const [deptModalOpen, setDeptModalOpen] = useState(false); - const [positionModalOpen, setPositionModalOpen] = useState(false); - const [editOrg, setEditOrg] = useState(null); - - const [orgForm] = Form.useForm(); - const [deptForm] = Form.useForm(); - const [positionForm] = Form.useForm(); + // --- Ref for drawer onSuccess callback (avoids before-declaration issue) --- + const refreshOrgTreeRef = useRef<() => void>(() => {}); // --- Fetch org tree --- const fetchOrgTree = useCallback(async () => { - setLoading(true); try { const tree = await listOrgTree(); setOrgTree(tree); if (selectedOrg) { const stillExists = findOrgInTree(tree, selectedOrg.id); - if (!stillExists) { - setSelectedOrg(null); - setDeptTree([]); - setPositions([]); - } + if (!stillExists) { setSelectedOrg(null); setDeptTree([]); setPositions([]); } } - } catch { - message.error('加载组织树失败'); - } - setLoading(false); + } catch { /* silent */ } }, [selectedOrg]); - useEffect(() => { - fetchOrgTree(); - }, [fetchOrgTree]); + refreshOrgTreeRef.current = () => { fetchOrgTree(); }; - // --- Fetch dept tree when org selected --- + useEffect(() => { fetchOrgTree(); }, [fetchOrgTree]); + + // --- Dept Drawer --- + const [deptDrawerOpen, setDeptDrawerOpen] = useState(false); + + // --- Position Drawer --- + const [positionDrawerOpen, setPositionDrawerOpen] = useState(false); + + // --- Org Drawer (uses ref to avoid before-declaration) --- + const orgDrawer = useCrudDrawer({ + getId: (r) => r.id, + onCreate: async (values) => { + await createOrg({ ...(values as { name: string; code?: string; sort_order?: number }), parent_id: selectedOrg?.id }); + }, + onUpdate: async (id, values) => { + await updateOrg(id, values as { name: string; code?: string; sort_order?: number; version: number }); + }, + onSuccess: () => { refreshOrgTreeRef.current(); }, + }); + + // --- Fetch dept tree --- const fetchDeptTree = useCallback(async () => { if (!selectedOrg) return; try { @@ -101,209 +104,87 @@ export default function Organizations() { setDeptTree(tree); if (selectedDept) { const stillExists = findDeptInTree(tree, selectedDept.id); - if (!stillExists) { - setSelectedDept(null); - setPositions([]); - } + if (!stillExists) { setSelectedDept(null); setPositions([]); } } - } catch { - message.error('加载部门树失败'); - } + } catch { /* silent */ } }, [selectedOrg, selectedDept]); - useEffect(() => { - fetchDeptTree(); - }, [fetchDeptTree]); + useEffect(() => { fetchDeptTree(); }, [fetchDeptTree]); - // --- Fetch positions when dept selected --- + // --- Fetch positions --- const fetchPositions = useCallback(async () => { if (!selectedDept) return; try { - const list = await listPositions(selectedDept.id); - setPositions(list); - } catch { - message.error('加载岗位列表失败'); - } + setPositions(await listPositions(selectedDept.id)); + } catch { /* silent */ } }, [selectedDept]); - useEffect(() => { - fetchPositions(); - }, [fetchPositions]); + useEffect(() => { fetchPositions(); }, [fetchPositions]); // --- Org handlers --- - const handleCreateOrg = async (values: { - name: string; - code?: string; - sort_order?: number; - }) => { - try { - if (editOrg) { - await updateOrg(editOrg.id, { - name: values.name, - code: values.code, - sort_order: values.sort_order, - version: editOrg.version, - }); - message.success('组织更新成功'); - } else { - await createOrg({ - name: values.name, - code: values.code, - parent_id: selectedOrg?.id, - sort_order: values.sort_order, - }); - message.success('组织创建成功'); - } - setOrgModalOpen(false); - setEditOrg(null); - orgForm.resetFields(); - fetchOrgTree(); - } catch (err: unknown) { - handleApiError(err, '操作失败'); - } - }; - const handleDeleteOrg = async (id: string) => { - try { - await deleteOrg(id); - message.success('组织已删除'); - setSelectedOrg(null); - setDeptTree([]); - setPositions([]); - fetchOrgTree(); - } catch (err: unknown) { - handleApiError(err, '删除失败'); - } + await execute(() => deleteOrg(id), '组织已删除'); + setSelectedOrg(null); setDeptTree([]); setPositions([]); + fetchOrgTree(); }; // --- Dept handlers --- - const handleCreateDept = async (values: { - name: string; - code?: string; - sort_order?: number; - }) => { + const handleCreateDept = async (values: Record) => { if (!selectedOrg) return; - try { - await createDept(selectedOrg.id, { - name: values.name, - code: values.code, - parent_id: selectedDept?.id, - sort_order: values.sort_order, - }); - message.success('部门创建成功'); - setDeptModalOpen(false); - deptForm.resetFields(); - fetchDeptTree(); - } catch (err: unknown) { - handleApiError(err, '操作失败'); - } + await execute(() => createDept(selectedOrg.id, { + name: values.name as string, code: values.code as string | undefined, + parent_id: selectedDept?.id, sort_order: values.sort_order as number | undefined, + }), '部门创建成功'); + setDeptDrawerOpen(false); + fetchDeptTree(); }; const handleDeleteDept = async (id: string) => { - try { - await deleteDept(id); - message.success('部门已删除'); - setSelectedDept(null); - setPositions([]); - fetchDeptTree(); - } catch (err: unknown) { - handleApiError(err, '删除失败'); - } + await execute(() => deleteDept(id), '部门已删除'); + setSelectedDept(null); setPositions([]); + fetchDeptTree(); }; // --- Position handlers --- - const handleCreatePosition = async (values: { - name: string; - code?: string; - level?: number; - sort_order?: number; - }) => { + const handleCreatePosition = async (values: Record) => { if (!selectedDept) return; - try { - await createPosition(selectedDept.id, { - name: values.name, - code: values.code, - level: values.level, - sort_order: values.sort_order, - }); - message.success('岗位创建成功'); - setPositionModalOpen(false); - positionForm.resetFields(); - fetchPositions(); - } catch (err: unknown) { - handleApiError(err, '操作失败'); - } + await execute(() => createPosition(selectedDept.id, { + name: values.name as string, code: values.code as string | undefined, + level: values.level as number | undefined, sort_order: values.sort_order as number | undefined, + }), '岗位创建成功'); + setPositionDrawerOpen(false); + fetchPositions(); }; const handleDeletePosition = async (id: string) => { - try { - await deletePosition(id); - message.success('岗位已删除'); - fetchPositions(); - } catch { - message.error('删除失败'); - } + await execute(() => deletePosition(id), '岗位已删除'); + fetchPositions(); }; // --- Tree node converters --- const convertOrgTree = (items: OrganizationInfo[]): DataNode[] => items.map((item) => ({ key: item.id, - title: ( - - {item.name}{' '} - {item.code && {item.code}} - - ), + title: {item.name} {item.code && {item.code}}, children: convertOrgTree(item.children), })); const convertDeptTree = (items: DepartmentInfo[]): DataNode[] => items.map((item) => ({ key: item.id, - title: ( - - {item.name}{' '} - {item.code && {item.code}} - - ), + title: {item.name} {item.code && {item.code}}, children: convertDeptTree(item.children), })); const onSelectOrg = (selectedKeys: React.Key[]) => { - if (selectedKeys.length === 0) { - setSelectedOrg(null); - setDeptTree([]); - setSelectedDept(null); - setPositions([]); - return; - } - const org = findOrgInTree(orgTree, selectedKeys[0] as string); - setSelectedOrg(org); - setSelectedDept(null); - setPositions([]); + if (selectedKeys.length === 0) { setSelectedOrg(null); setDeptTree([]); setSelectedDept(null); setPositions([]); return; } + setSelectedOrg(findOrgInTree(orgTree, selectedKeys[0] as string)); + setSelectedDept(null); setPositions([]); }; const onSelectDept = (selectedKeys: React.Key[]) => { - if (selectedKeys.length === 0) { - setSelectedDept(null); - setPositions([]); - return; - } - const dept = findDeptInTree(deptTree, selectedKeys[0] as string); - setSelectedDept(dept); + if (selectedKeys.length === 0) { setSelectedDept(null); setPositions([]); return; } + setSelectedDept(findDeptInTree(deptTree, selectedKeys[0] as string)); }; const positionColumns = [ @@ -312,16 +193,10 @@ export default function Organizations() { { title: '级别', dataIndex: 'level', key: 'level' }, { title: '排序', dataIndex: 'sort_order', key: 'sort_order' }, { - title: '操作', - key: 'actions', + title: '操作', key: 'actions', render: (_: unknown, record: PositionInfo) => ( - handleDeletePosition(record.id)} - > - + handleDeletePosition(record.id)}> + ), }, @@ -329,60 +204,29 @@ export default function Organizations() { return (
- {/* 页面标题 */}
-

- - 组织架构管理 -

+

组织架构管理

管理组织、部门和岗位的层级结构
- {/* 三栏布局 */}
{/* 左栏:组织树 */}
组织 -
{orgTree.length > 0 ? ( - - ) : ( - - )} + + ) : }
{/* 中栏:部门树 */}
- - {selectedOrg ? `${selectedOrg.name} · 部门` : '部门'} - + {selectedOrg ? `${selectedOrg.name} · 部门` : '部门'} {selectedOrg && ( -
{/* 右栏:岗位表 */}
- - {selectedDept ? `${selectedDept.name} · 岗位` : '岗位'} - + {selectedDept ? `${selectedDept.name} · 岗位` : '岗位'} {selectedDept && ( - + )}
{selectedDept ? ( - - ) : ( -
- -
- )} +
+ ) :
} - {/* Org Modal */} - { - setOrgModalOpen(false); - setEditOrg(null); - }} - onOk={() => orgForm.submit()} + {/* Org Drawer */} + -
- - - - - - - - - - -
+ + + + + + + + - {/* Dept Modal */} - setDeptModalOpen(false)} - onOk={() => deptForm.submit()} + {/* Dept Drawer */} + setDeptDrawerOpen(false)} + onSubmit={handleCreateDept} + initialValues={{ sort_order: 0 }} + loading={false} + width={480} + columns={1} > -
- - - - - - - - - - -
+ + + + + + + + - {/* Position Modal */} - setPositionModalOpen(false)} - onOk={() => positionForm.submit()} + open={positionDrawerOpen} + onClose={() => setPositionDrawerOpen(false)} + onSubmit={handleCreatePosition} + initialValues={{ level: 1, sort_order: 0 }} + loading={false} + width={480} + columns={1} > -
- - - - - - - - - - - - - -
+ + + + + + + + + + + ); } -// --- Helpers --- - -function findOrgInTree( - tree: OrganizationInfo[], - id: string, -): OrganizationInfo | null { +function findOrgInTree(tree: OrganizationInfo[], id: string): OrganizationInfo | null { for (const item of tree) { if (item.id === id) return item; const found = findOrgInTree(item.children, id); @@ -600,10 +361,7 @@ function findOrgInTree( return null; } -function findDeptInTree( - tree: DepartmentInfo[], - id: string, -): DepartmentInfo | null { +function findDeptInTree(tree: DepartmentInfo[], id: string): DepartmentInfo | null { for (const item of tree) { if (item.id === id) return item; const found = findDeptInTree(item.children, id); diff --git a/apps/web/src/pages/Roles.tsx b/apps/web/src/pages/Roles.tsx index 3732acb..e1fa79b 100644 --- a/apps/web/src/pages/Roles.tsx +++ b/apps/web/src/pages/Roles.tsx @@ -1,15 +1,13 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { Table, Button, Space, - Modal, Form, Input, Tag, Popconfirm, Checkbox, - message, } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyCertificateOutlined } from '@ant-design/icons'; import { @@ -23,78 +21,48 @@ import { type RoleInfo, type PermissionInfo, } from '../api/roles'; -import { handleApiError } from '../api/client'; +import { PageContainer } from '../components/PageContainer'; +import { DrawerForm } from '../components/DrawerForm'; +import { useCrudDrawer } from '../hooks/useCrudDrawer'; +import { useApiRequest } from '../hooks/useApiRequest'; import { useThemeMode } from '../hooks/useThemeMode'; +import { useListData } from '../hooks/useListData'; export default function Roles() { - const [roles, setRoles] = useState([]); + const isDark = useThemeMode(); + const { execute } = useApiRequest(); + + const { data: roles, loading, refresh } = useListData(async () => { + const result = await listRoles(); + return result.data; + }); + const [permissions, setPermissions] = useState([]); - const [loading, setLoading] = useState(false); - const [createModalOpen, setCreateModalOpen] = useState(false); - const [editRole, setEditRole] = useState(null); - const [permModalOpen, setPermModalOpen] = useState(false); + const [permDrawerOpen, setPermDrawerOpen] = useState(false); const [selectedRole, setSelectedRole] = useState(null); const [selectedPermIds, setSelectedPermIds] = useState([]); - const [form] = Form.useForm(); - const isDark = useThemeMode(); - - const fetchRoles = useCallback(async () => { - setLoading(true); - try { - const result = await listRoles(); - setRoles(result.data); - } catch { - message.error('加载角色失败'); - } - setLoading(false); - }, []); - - const fetchPermissions = useCallback(async () => { - try { - setPermissions(await listPermissions()); - } catch { - // 静默处理 - } - }, []); useEffect(() => { - fetchRoles(); - fetchPermissions(); - }, [fetchRoles, fetchPermissions]); + listPermissions().then(setPermissions).catch(() => {}); + }, []); - const handleCreate = async (values: { - name: string; - code: string; - description?: string; - }) => { - try { - if (editRole) { - await updateRole(editRole.id, { ...values, version: editRole.version }); - message.success('角色更新成功'); - } else { - await createRole(values); - message.success('角色创建成功'); - } - setCreateModalOpen(false); - setEditRole(null); - form.resetFields(); - fetchRoles(); - } catch (err: unknown) { - handleApiError(err, '操作失败'); - } - }; + const roleDrawer = useCrudDrawer({ + getId: (r) => r.id, + onCreate: async (values) => { + await createRole(values as unknown as { name: string; code: string; description?: string }); + }, + onUpdate: async (id, values) => { + await updateRole(id, values as unknown as { name: string; code: string; description?: string; version: number }); + }, + onSuccess: refresh, + }); const handleDelete = async (id: string) => { - try { - await deleteRole(id); - message.success('角色已删除'); - fetchRoles(); - } catch { - message.error('删除失败'); - } + await execute(() => deleteRole(id), '角色已删除'); + refresh(); }; - const openPermModal = async (role: RoleInfo) => { + const openPermDrawer = async (role: RoleInfo) => { setSelectedRole(role); try { const rolePerms = await getRolePermissions(role.id); @@ -102,64 +70,26 @@ export default function Roles() { } catch { setSelectedPermIds([]); } - setPermModalOpen(true); + setPermDrawerOpen(true); }; const savePermissions = async () => { if (!selectedRole) return; - try { - await assignPermissions(selectedRole.id, selectedPermIds); - message.success('权限分配成功'); - setPermModalOpen(false); - } catch { - message.error('权限分配失败'); - } - }; - - const openEditModal = (role: RoleInfo) => { - setEditRole(role); - form.setFieldsValue({ - name: role.name, - code: role.code, - description: role.description, - }); - setCreateModalOpen(true); - }; - - const openCreateModal = () => { - setEditRole(null); - form.resetFields(); - setCreateModalOpen(true); - }; - - const closeCreateModal = () => { - setCreateModalOpen(false); - setEditRole(null); - form.resetFields(); + await execute(() => assignPermissions(selectedRole.id, selectedPermIds), '权限分配成功'); + setPermDrawerOpen(false); }; const columns = [ { - title: '角色名称', - dataIndex: 'name', - key: 'name', + title: '角色名称', dataIndex: 'name', key: 'name', render: (v: string, record: RoleInfo) => (
-
+
{v} @@ -167,82 +97,33 @@ export default function Roles() { ), }, { - title: '编码', - dataIndex: 'code', - key: 'code', - render: (v: string) => ( - - {v} - - ), + title: '编码', dataIndex: 'code', key: 'code', + render: (v: string) => {v}, }, { - title: '描述', - dataIndex: 'description', - key: 'description', - ellipsis: true, - render: (v: string | undefined) => ( - {v || '-'} - ), + title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, + render: (v?: string) => {v || '-'}, }, { - title: '类型', - dataIndex: 'is_system', - key: 'is_system', - width: 100, + title: '类型', dataIndex: 'is_system', key: 'is_system', width: 100, render: (v: boolean) => ( - + {v ? '系统' : '自定义'} ), }, { - title: '操作', - key: 'actions', - width: 180, + title: '操作', key: 'actions', width: 180, render: (_: unknown, record: RoleInfo) => ( - + {!record.is_system && ( <> - -
+ } onClick={() => roleDrawer.openCreate()}>新建角色} + > +
`共 ${t} 条记录` }} + /> - {/* 表格容器 */} -
-
`共 ${t} 条记录` }} - /> - - - {/* 新建/编辑角色弹窗 */} - form.submit()} + {/* 新建/编辑角色 Drawer */} + -
- - - - - - - - - - -
+ + + + + + + + + + - {/* 权限分配弹窗 */} - setPermModalOpen(false)} - onOk={savePermissions} + open={permDrawerOpen} + onClose={() => setPermDrawerOpen(false)} + onSubmit={savePermissions} + initialValues={{}} + loading={false} width={600} + columns={1} >
{Object.entries(groupedPermissions).map(([resource, perms]) => ( -
-
+
+
{resource}
{perms.map((p) => ( - - {p.name} - + {p.name} ))}
))}
- -
+ + ); } diff --git a/apps/web/src/pages/Users.tsx b/apps/web/src/pages/Users.tsx index be68d06..17f0221 100644 --- a/apps/web/src/pages/Users.tsx +++ b/apps/web/src/pages/Users.tsx @@ -1,19 +1,16 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Table, Button, Space, - Modal, Form, Input, Tag, Popconfirm, Checkbox, - message, } from 'antd'; import { PlusOutlined, - SearchOutlined, EditOutlined, DeleteOutlined, UserOutlined, @@ -32,311 +29,132 @@ import { } from '../api/users'; import { listRoles, type RoleInfo } from '../api/roles'; import type { UserInfo } from '../api/auth'; -import { handleApiError } from '../api/client'; +import { PageContainer } from '../components/PageContainer'; +import { DrawerForm } from '../components/DrawerForm'; +import { useCrudDrawer } from '../hooks/useCrudDrawer'; +import { usePaginatedData } from '../hooks/usePaginatedData'; +import { useApiRequest } from '../hooks/useApiRequest'; import { useThemeMode } from '../hooks/useThemeMode'; -const STATUS_COLOR_MAP: Record = { - active: '#059669', - disabled: '#dc2626', - locked: '#d97706', -}; - -const STATUS_BG_MAP: Record = { - active: '#ECFDF5', - disabled: '#FEF2F2', - locked: '#FFFBEB', -}; - -const STATUS_LABEL_MAP: Record = { - active: '正常', - disabled: '禁用', - locked: '锁定', -}; +const STATUS_COLOR_MAP: Record = { active: '#059669', disabled: '#dc2626', locked: '#d97706' }; +const STATUS_BG_MAP: Record = { active: '#ECFDF5', disabled: '#FEF2F2', locked: '#FFFBEB' }; +const STATUS_LABEL_MAP: Record = { active: '正常', disabled: '禁用', locked: '锁定' }; export default function Users() { - const [users, setUsers] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [searchText, setSearchText] = useState(''); - const [createModalOpen, setCreateModalOpen] = useState(false); - const [editUser, setEditUser] = useState(null); - const [roleModalOpen, setRoleModalOpen] = useState(false); - const [selectedUser, setSelectedUser] = useState(null); - const [allRoles, setAllRoles] = useState([]); - const [selectedRoleIds, setSelectedRoleIds] = useState([]); - const [form] = Form.useForm(); const isDark = useThemeMode(); + const { execute } = useApiRequest(); - const fetchUsers = useCallback(async (p = page) => { - setLoading(true); - try { - const result = await listUsers(p, 20, searchText); - setUsers(result.data); - setTotal(result.total); - } catch { - message.error('加载用户列表失败'); - } - setLoading(false); - }, [page, searchText]); + const { + data: users, total, page, loading, refresh, + } = usePaginatedData(async (p, pageSize, search) => { + const result = await listUsers(p, pageSize, search); + return { data: result.data, total: result.total }; + }, 20); - // 搜索防抖:输入后 300ms 才触发查询 - const debounceTimer = useRef | null>(null); - const debouncedSearch = useCallback((_text: string) => { - if (debounceTimer.current) clearTimeout(debounceTimer.current); - debounceTimer.current = setTimeout(() => { - setPage(1); - }, 300); - }, []); + const [allRoles, setAllRoles] = useState([]); + const [roleDrawerOpen, setRoleDrawerOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); const fetchRoles = useCallback(async () => { try { const result = await listRoles(); setAllRoles(result.data); - } catch { - // 静默处理 - } + } catch { /* silent */ } }, []); - useEffect(() => { - fetchUsers(); - fetchRoles(); - }, [fetchUsers, fetchRoles]); + useEffect(() => { fetchRoles(); }, [fetchRoles]); - const handleCreateOrEdit = async (values: { - username: string; - password?: string; - display_name?: string; - email?: string; - phone?: string; - }) => { - try { - if (editUser) { - const req: UpdateUserRequest = { - display_name: values.display_name, - email: values.email, - phone: values.phone, - version: editUser.version, - }; - await updateUser(editUser.id, req); - message.success('用户更新成功'); - } else { - const req: CreateUserRequest = { - username: values.username, - password: values.password ?? '', - display_name: values.display_name, - email: values.email, - phone: values.phone, - }; - await createUser(req); - message.success('用户创建成功'); - } - setCreateModalOpen(false); - setEditUser(null); - form.resetFields(); - fetchUsers(); - } catch (err: unknown) { - handleApiError(err, '操作失败'); - } - }; + const userDrawer = useCrudDrawer({ + getId: (r) => r.id, + onCreate: async (values) => { + await createUser(values as unknown as CreateUserRequest); + }, + onUpdate: async (id, values) => { + await updateUser(id, values as unknown as UpdateUserRequest); + }, + onSuccess: refresh, + }); const handleDelete = async (id: string) => { - try { - await deleteUser(id); - message.success('用户已删除'); - fetchUsers(); - } catch { - message.error('删除失败'); - } + await execute(() => deleteUser(id), '用户已删除'); + refresh(); }; const handleToggleStatus = async (id: string, status: string) => { - try { - const user = users.find(u => u.id === id); - if (!user) return; - await updateUser(id, { status, version: user.version }); - message.success(status === 'disabled' ? '用户已禁用' : '用户已启用'); - fetchUsers(); - } catch { - message.error('状态更新失败'); - } + const user = users.find(u => u.id === id); + if (!user) return; + await execute(() => updateUser(id, { status, version: user.version }), status === 'disabled' ? '用户已禁用' : '用户已启用'); + refresh(); }; const handleAssignRoles = async () => { if (!selectedUser) return; - try { - await assignRoles(selectedUser.id, selectedRoleIds); - message.success('角色分配成功'); - setRoleModalOpen(false); - fetchUsers(); - } catch { - message.error('角色分配失败'); - } + await execute(() => assignRoles(selectedUser.id, selectedRoleIds), '角色分配成功'); + setRoleDrawerOpen(false); + refresh(); }; - const openCreateModal = () => { - setEditUser(null); - form.resetFields(); - setCreateModalOpen(true); - }; - - const openEditModal = (user: UserInfo) => { - setEditUser(user); - form.setFieldsValue({ - username: user.username, - display_name: user.display_name, - email: user.email, - phone: user.phone, - }); - setCreateModalOpen(true); - }; - - const closeCreateModal = () => { - setCreateModalOpen(false); - setEditUser(null); - form.resetFields(); - }; - - const openRoleModal = (user: UserInfo) => { + const openRoleDrawer = (user: UserInfo) => { setSelectedUser(user); setSelectedRoleIds(user.roles.map((r) => r.id)); - setRoleModalOpen(true); + setRoleDrawerOpen(true); }; - const filteredUsers = users; - const columns = [ { - title: '用户', - dataIndex: 'username', - key: 'username', + title: '用户', dataIndex: 'username', key: 'username', render: (v: string, record: UserInfo) => (
-
+
{(record.display_name?.[0] || v?.[0] || 'U').toUpperCase()}
{v}
- {record.display_name && ( -
- {record.display_name} -
- )} + {record.display_name &&
{record.display_name}
}
), }, + { title: '邮箱', dataIndex: 'email', key: 'email', render: (v?: string) => v || '-' }, + { title: '电话', dataIndex: 'phone', key: 'phone', render: (v?: string) => v || '-' }, { - title: '邮箱', - dataIndex: 'email', - key: 'email', - render: (v: string | undefined) => v || '-', - }, - { - title: '电话', - dataIndex: 'phone', - key: 'phone', - render: (v: string | undefined) => v || '-', - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - width: 100, + title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => ( - + {STATUS_LABEL_MAP[status] || status} ), }, { - title: '角色', - dataIndex: 'roles', - key: 'roles', + title: '角色', dataIndex: 'roles', key: 'roles', render: (roles: RoleInfo[]) => roles.length > 0 - ? roles.map((r) => ( - - {r.name} - - )) + ? roles.map((r) => {r.name}) : -, }, { - title: '操作', - key: 'actions', - width: 240, + title: '操作', key: 'actions', width: 240, render: (_: unknown, record: UserInfo) => ( - - -
+ } onClick={() => userDrawer.openCreate()}>新建用户} + > +
refresh(p), + showTotal: (t) => `共 ${t} 条记录`, + }} + /> - {/* 表格容器 */} -
-
{ - setPage(p); - fetchUsers(p); - }, - showTotal: (t) => `共 ${t} 条记录`, - style: { padding: '12px 16px', margin: 0 }, - }} - /> - - - {/* 新建/编辑用户弹窗 */} - form.submit()} + {/* 新建/编辑用户 Drawer */} + -
- - } disabled={!!editUser} /> + + } disabled={!!userDrawer.editingRecord} /> + + {!userDrawer.editingRecord && ( + + - {!editUser && ( - - - - )} - - - - - - - - - - -
+ )} + + + + + + - {/* 角色分配弹窗 */} - setRoleModalOpen(false)} - onOk={handleAssignRoles} - width={480} + open={roleDrawerOpen} + onClose={() => setRoleDrawerOpen(false)} + onSubmit={async () => { await handleAssignRoles(); }} + initialValues={{}} + loading={false} + width={520} + columns={1} >
{allRoles.map((r) => ( -
+
{r.name} - - {r.code} - + {r.code}
))}
- -
+ + ); } diff --git a/apps/web/src/pages/settings/AuditLogViewer.tsx b/apps/web/src/pages/settings/AuditLogViewer.tsx index 59d1daf..a008853 100644 --- a/apps/web/src/pages/settings/AuditLogViewer.tsx +++ b/apps/web/src/pages/settings/AuditLogViewer.tsx @@ -1,33 +1,23 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Table, Select, Input, Tag, message } from 'antd'; +import { useEffect, useRef } from 'react'; +import { Table, Select, Input, Tag } from 'antd'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs'; import { listUsers } from '../../api/users'; import { useThemeMode } from '../../hooks/useThemeMode'; +import { usePaginatedData } from '../../hooks/usePaginatedData'; const RESOURCE_TYPE_OPTIONS = [ - { value: 'user', label: '用户' }, - { value: 'role', label: '角色' }, - { value: 'position', label: '岗位' }, - { value: 'organization', label: '组织' }, - { value: 'department', label: '部门' }, - { value: 'process_instance', label: '流程实例' }, - { value: 'process_definition', label: '流程定义' }, - { value: 'task', label: '流程任务' }, - { value: 'dictionary', label: '字典' }, - { value: 'menu', label: '菜单' }, - { value: 'setting', label: '设置' }, - { value: 'numbering_rule', label: '编号规则' }, - { value: 'patient', label: '患者' }, - { value: 'patient_tag', label: '患者标签' }, - { value: 'patient_family_member', label: '家庭成员' }, - { value: 'patient_doctor_relation', label: '医患关系' }, - { value: 'points_transaction', label: '积分流水' }, - { value: 'points_product', label: '积分商品' }, - { value: 'points_order', label: '积分订单' }, - { value: 'points_rule', label: '积分规则' }, - { value: 'offline_event', label: '线下活动' }, - { value: 'offline_event_registration', label: '活动签到' }, + { value: 'user', label: '用户' }, { value: 'role', label: '角色' }, + { value: 'position', label: '岗位' }, { value: 'organization', label: '组织' }, + { value: 'department', label: '部门' }, { value: 'process_instance', label: '流程实例' }, + { value: 'process_definition', label: '流程定义' }, { value: 'task', label: '流程任务' }, + { value: 'dictionary', label: '字典' }, { value: 'menu', label: '菜单' }, + { value: 'setting', label: '设置' }, { value: 'numbering_rule', label: '编号规则' }, + { value: 'patient', label: '患者' }, { value: 'patient_tag', label: '患者标签' }, + { value: 'patient_family_member', label: '家庭成员' }, { value: 'patient_doctor_relation', label: '医患关系' }, + { value: 'points_transaction', label: '积分流水' }, { value: 'points_product', label: '积分商品' }, + { value: 'points_order', label: '积分订单' }, { value: 'points_rule', label: '积分规则' }, + { value: 'offline_event', label: '线下活动' }, { value: 'offline_event_registration', label: '活动签到' }, ]; const ACTION_STYLES: Record = { @@ -38,37 +28,25 @@ const ACTION_STYLES: Record function formatDateTime(value: string): string { return new Date(value).toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', }); } export default function AuditLogViewer() { - const [logs, setLogs] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [query, setQuery] = useState({ page: 1, page_size: 20 }); const isDark = useThemeMode(); const userNameCache = useRef>({}); const cacheLoaded = useRef(false); - const fetchLogs = useCallback(async (params: AuditLogQuery) => { - setLoading(true); - try { - const result = await listAuditLogs(params); - setLogs(result.data); - setTotal(result.total); - } catch { - message.error('加载审计日志失败'); - } - setLoading(false); - }, []); + const { data: logs, total, page, loading, filters, setFilters, refresh } = usePaginatedData( + async (p, pageSize, query) => { + const result = await listAuditLogs({ ...query, page: p, page_size: pageSize }); + return { data: result.data, total: result.total }; + }, + { pageSize: 20, defaultFilters: { page: 1, page_size: 20 } as unknown as AuditLogQuery, autoFetch: false }, + ); - // 加载用户名称缓存(分页遍历所有用户) + // Load user name cache useEffect(() => { if (cacheLoaded.current) return; let cancelled = false; @@ -85,109 +63,45 @@ export default function AuditLogViewer() { hasMore = result.data.length >= pageSize; currentPage += 1; } - if (!cancelled) { - cacheLoaded.current = true; - } - } catch { - // 静默失败,将显示 UUID - } + if (!cancelled) cacheLoaded.current = true; + } catch { /* silent */ } }; loadAllUsers(); return () => { cancelled = true; }; }, []); - useEffect(() => { - fetchLogs(query); - }, [query, fetchLogs]); + useEffect(() => { refresh(1); }, []); const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => { - setQuery((prev) => ({ - ...prev, - [field]: value || undefined, - page: 1, - })); - }; - - const handleTableChange = (pagination: TablePaginationConfig) => { - setQuery((prev) => ({ - ...prev, - page: pagination.current, - page_size: pagination.pageSize, - })); + setFilters((prev) => ({ ...prev, [field]: value || undefined, page: 1 })); }; const columns: ColumnsType = [ { - title: '操作', - dataIndex: 'action', - key: 'action', - width: 100, + title: '操作', dataIndex: 'action', key: 'action', width: 100, render: (action: string) => { const info = ACTION_STYLES[action] || { bg: '#f8fafc', color: '#475569', text: action }; - return ( - - {info.text} - - ); + return {info.text}; }, }, { - title: '资源类型', - dataIndex: 'resource_type', - key: 'resource_type', - width: 120, - render: (v: string) => ( - - {v} - - ), + title: '资源类型', dataIndex: 'resource_type', key: 'resource_type', width: 120, + render: (v: string) => {v}, }, { - title: '资源 ID', - dataIndex: 'resource_id', - key: 'resource_id', - width: 200, - ellipsis: true, - render: (v: string) => ( - - {v} - - ), + title: '资源 ID', dataIndex: 'resource_id', key: 'resource_id', width: 200, ellipsis: true, + render: (v: string) => {v}, }, { - title: '操作用户', - dataIndex: 'user_id', - key: 'user_id', - width: 200, - ellipsis: true, + title: '操作用户', dataIndex: 'user_id', key: 'user_id', width: 200, ellipsis: true, render: (v: string) => { const name = userNameCache.current[v]; - return ( - - {name || v} - - ); + return {name || v}; }, }, { - title: '时间', - dataIndex: 'created_at', - key: 'created_at', - width: 180, - render: (value: string) => ( - - {formatDateTime(value)} - - ), + title: '时间', dataIndex: 'created_at', key: 'created_at', width: 180, + render: (value: string) => {formatDateTime(value)}, }, ]; @@ -195,54 +109,35 @@ export default function AuditLogViewer() {
{/* 筛选工具栏 */}
handleFilterChange('user_id', e.target.value)} /> - - 共 {total} 条日志 - + 共 {total} 条日志
{/* 表格 */}
refresh(pagination.current)} pagination={{ - current: query.page, - pageSize: query.page_size, - total, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条`, + current: page, pageSize: 20, total, + showSizeChanger: true, showTotal: (t) => `共 ${t} 条`, }} scroll={{ x: 900 }} /> diff --git a/apps/web/src/pages/settings/ChangePassword.tsx b/apps/web/src/pages/settings/ChangePassword.tsx index c2aae72..f133577 100644 --- a/apps/web/src/pages/settings/ChangePassword.tsx +++ b/apps/web/src/pages/settings/ChangePassword.tsx @@ -1,13 +1,12 @@ -import { Form, Input, Button, message, Card, Typography } from 'antd'; +import { Form, Input, Button, Card, Typography } from 'antd'; import { LockOutlined } from '@ant-design/icons'; import { useAuthStore } from '../../stores/auth'; import { changePassword } from '../../api/auth'; import { useNavigate } from 'react-router-dom'; - -const { Title } = Typography; +import { useApiRequest } from '../../hooks/useApiRequest'; export default function ChangePassword() { - const [messageApi, contextHolder] = message.useMessage(); + const { execute } = useApiRequest(); const logout = useAuthStore((s) => s.logout); const navigate = useNavigate(); const [form] = Form.useForm(); @@ -18,84 +17,53 @@ export default function ChangePassword() { confirm_password: string; }) => { if (values.new_password !== values.confirm_password) { - messageApi.error('两次输入的新密码不一致'); return; } - try { + await execute(async () => { await changePassword(values.current_password, values.new_password); - messageApi.success('密码修改成功,请重新登录'); await logout(); navigate('/login'); - } catch (err: unknown) { - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '密码修改失败'; - messageApi.error(errorMsg); - } + }, '密码修改成功,请重新登录', '密码修改失败'); }; return ( - {contextHolder} - - 修改密码 - + 修改密码
- - } - placeholder="请输入当前密码" - /> + + } placeholder="请输入当前密码" /> ({ validator(_, value) { - if (!value || getFieldValue('current_password') !== value) { - return Promise.resolve(); - } + if (!value || getFieldValue('current_password') !== value) return Promise.resolve(); return Promise.reject(new Error('新密码不能与当前密码相同')); }, }), ]} > - } - placeholder="请输入新密码(至少8位)" - /> + } placeholder="请输入新密码(至少8位)" /> ({ validator(_, value) { - if (!value || getFieldValue('new_password') === value) { - return Promise.resolve(); - } + if (!value || getFieldValue('new_password') === value) return Promise.resolve(); return Promise.reject(new Error('两次输入的密码不一致')); }, }), ]} > - } - placeholder="请再次输入新密码" - /> + } placeholder="请再次输入新密码" /> - +
diff --git a/apps/web/src/pages/settings/DictionaryManager.tsx b/apps/web/src/pages/settings/DictionaryManager.tsx index 9c1a973..4b69344 100644 --- a/apps/web/src/pages/settings/DictionaryManager.tsx +++ b/apps/web/src/pages/settings/DictionaryManager.tsx @@ -1,14 +1,12 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useState } from 'react'; import { Table, Button, Space, - Modal, Form, Input, InputNumber, Popconfirm, - message, Typography, Tag, } from 'antd'; @@ -27,100 +25,46 @@ import { type CreateDictionaryItemRequest, type UpdateDictionaryItemRequest, } from '../../api/dictionaries'; - -// --- Types --- +import { useListData } from '../../hooks/useListData'; +import { useCrudDrawer } from '../../hooks/useCrudDrawer'; +import { DrawerForm } from '../../components/DrawerForm'; +import { useApiRequest } from '../../hooks/useApiRequest'; type DictItem = DictionaryItemInfo; type Dictionary = DictionaryInfo; -// --- Component --- - export default function DictionaryManager() { - const [dictionaries, setDictionaries] = useState([]); - const [loading, setLoading] = useState(false); - const [dictModalOpen, setDictModalOpen] = useState(false); - const [editDict, setEditDict] = useState(null); - const [itemModalOpen, setItemModalOpen] = useState(false); + const { data: dictionaries, loading, refresh } = useListData(async () => { + const result = await listDictionaries(); + return Array.isArray(result) ? result : result.data ?? []; + }); + + const { execute } = useApiRequest(); + + // 字典 CRUD Drawer + const dictDrawer = useCrudDrawer({ + getId: (r) => r.id, + onCreate: async (values) => { + await createDictionary(values as unknown as CreateDictionaryRequest); + }, + onUpdate: async (id, values) => { + await updateDictionary(id, values as unknown as CreateDictionaryRequest & { version: number }); + }, + onSuccess: refresh, + }); + + // 字典项 Drawer — 因需要 activeDictId,手写状态管理 + const [itemDrawerOpen, setItemDrawerOpen] = useState(false); const [activeDictId, setActiveDictId] = useState(null); const [editItem, setEditItem] = useState(null); - const [dictForm] = Form.useForm(); const [itemForm] = Form.useForm(); - const fetchDictionaries = useCallback(async () => { - setLoading(true); - try { - const result = await listDictionaries(); - setDictionaries(Array.isArray(result) ? result : result.data ?? []); - } catch { - message.error('加载字典列表失败'); - } - setLoading(false); - }, []); - - useEffect(() => { - fetchDictionaries(); - }, [fetchDictionaries]); - - // --- Dictionary CRUD --- - - const handleDictSubmit = async (values: CreateDictionaryRequest) => { - try { - if (editDict) { - await updateDictionary(editDict.id, { ...values, version: editDict.version }); - message.success('字典更新成功'); - } else { - await createDictionary(values); - message.success('字典创建成功'); - } - closeDictModal(); - fetchDictionaries(); - } catch (err: unknown) { - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '操作失败'; - message.error(errorMsg); - } - }; - - const handleDeleteDict = async (id: string, version: number) => { - try { - await deleteDictionary(id, version); - message.success('字典已删除'); - fetchDictionaries(); - } catch { - message.error('删除失败'); - } - }; - - const openEditDict = (dict: Dictionary) => { - setEditDict(dict); - dictForm.setFieldsValue({ - name: dict.name, - code: dict.code, - description: dict.description, - }); - setDictModalOpen(true); - }; - - const openCreateDict = () => { - setEditDict(null); - dictForm.resetFields(); - setDictModalOpen(true); - }; - - const closeDictModal = () => { - setDictModalOpen(false); - setEditDict(null); - dictForm.resetFields(); - }; - - // --- Dictionary Item CRUD --- - const openAddItem = (dictId: string) => { setActiveDictId(dictId); setEditItem(null); itemForm.resetFields(); - setItemModalOpen(true); + itemForm.setFieldsValue({ sort_order: 0 }); + setItemDrawerOpen(true); }; const openEditItem = (dictId: string, item: DictItem) => { @@ -132,75 +76,56 @@ export default function DictionaryManager() { sort_order: item.sort_order, color: item.color, }); - setItemModalOpen(true); + setItemDrawerOpen(true); }; - const handleItemSubmit = async (values: CreateDictionaryItemRequest & { sort_order: number }) => { - if (!activeDictId) return; - try { - if (editItem) { - await updateDictionaryItem(activeDictId, editItem.id, { ...values, version: editItem.version } as UpdateDictionaryItemRequest); - message.success('字典项更新成功'); - } else { - await createDictionaryItem(activeDictId, values); - message.success('字典项添加成功'); - } - closeItemModal(); - fetchDictionaries(); - } catch (err: unknown) { - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '操作失败'; - message.error(errorMsg); - } - }; - - const handleDeleteItem = async (dictId: string, itemId: string, version: number) => { - try { - await deleteDictionaryItem(dictId, itemId, version); - message.success('字典项已删除'); - fetchDictionaries(); - } catch { - message.error('删除失败'); - } - }; - - const closeItemModal = () => { - setItemModalOpen(false); + const closeItemDrawer = () => { + setItemDrawerOpen(false); setActiveDictId(null); setEditItem(null); itemForm.resetFields(); }; - // --- Columns --- + const handleItemSubmit = async (values: Record) => { + if (!activeDictId) return; + const itemValues = values as unknown as CreateDictionaryItemRequest & { sort_order: number }; + if (editItem) { + await execute( + () => updateDictionaryItem(activeDictId, editItem.id, { ...itemValues, version: editItem.version } as UpdateDictionaryItemRequest), + '字典项更新成功', + ); + } else { + await execute(() => createDictionaryItem(activeDictId, itemValues), '字典项添加成功'); + } + closeItemDrawer(); + refresh(); + }; + + const handleDeleteDict = async (id: string, version: number) => { + await execute(() => deleteDictionary(id, version), '字典已删除'); + refresh(); + }; + + const handleDeleteItem = async (dictId: string, itemId: string, version: number) => { + await execute(() => deleteDictionaryItem(dictId, itemId, version), '字典项已删除'); + refresh(); + }; const columns = [ { title: '名称', dataIndex: 'name', key: 'name' }, { title: '编码', dataIndex: 'code', key: 'code' }, - { - title: '说明', - dataIndex: 'description', - key: 'description', - ellipsis: true, - }, + { title: '说明', dataIndex: 'description', key: 'description', ellipsis: true }, { title: '操作', key: 'actions', render: (_: unknown, record: Dictionary) => ( - - - handleDeleteDict(record.id, record.version)} - > - + + + handleDeleteDict(record.id, record.version)}> + ), @@ -212,31 +137,17 @@ export default function DictionaryManager() { { title: '值', dataIndex: 'value', key: 'value' }, { title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 }, { - title: '颜色', - dataIndex: 'color', - key: 'color', - width: 80, - render: (color?: string) => - color ? {color} : '-', + title: '颜色', dataIndex: 'color', key: 'color', width: 80, + render: (color?: string) => color ? {color} : '-', }, { title: '操作', key: 'actions', render: (_: unknown, record: DictItem) => ( - - handleDeleteItem(dictId, record.id, record.version)} - > - + + handleDeleteItem(dictId, record.id, record.version)}> + ), @@ -245,17 +156,9 @@ export default function DictionaryManager() { return (
-
- - 数据字典管理 - -
@@ -279,68 +182,52 @@ export default function DictionaryManager() { }} /> - {/* Dictionary Modal */} - dictForm.submit()} + {/* 字典 Drawer */} + -
- - - - - - - - - - -
+ + + + + + + + + + - {/* Dictionary Item Modal */} - itemForm.submit()} + open={itemDrawerOpen} + onClose={closeItemDrawer} + onSubmit={handleItemSubmit} + initialValues={editItem ? { label: editItem.label, value: editItem.value, sort_order: editItem.sort_order, color: editItem.color } : { sort_order: 0 }} + loading={false} + width={480} + columns={1} > -
- - - - - - - - - - - - - -
+ + + + + + + + + + + + +
); } diff --git a/apps/web/src/pages/settings/LanguageManager.tsx b/apps/web/src/pages/settings/LanguageManager.tsx index e573e74..4b35e15 100644 --- a/apps/web/src/pages/settings/LanguageManager.tsx +++ b/apps/web/src/pages/settings/LanguageManager.tsx @@ -1,13 +1,10 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useState } from 'react'; import { Table, Switch, - Modal, Button, Space, Typography, - message, - Card, Form, Input, } from 'antd'; @@ -17,113 +14,53 @@ import { updateLanguage, type LanguageInfo, } from '../../api/languages'; +import { useListData } from '../../hooks/useListData'; +import { DrawerForm } from '../../components/DrawerForm'; +import { useApiRequest } from '../../hooks/useApiRequest'; export default function LanguageManager() { - const [languages, setLanguages] = useState([]); - const [loading, setLoading] = useState(false); - const [editModalOpen, setEditModalOpen] = useState(false); + const { data: languages, loading, refresh } = useListData(listLanguages); + const { execute } = useApiRequest(); + + // LanguageInfo 没有 version/id 字段,手动管理 drawer 状态 + const [drawerOpen, setDrawerOpen] = useState(false); const [editingLang, setEditingLang] = useState(null); - const [form] = Form.useForm<{ name: string }>(); - - const fetchLanguages = useCallback(async () => { - setLoading(true); - try { - const result = await listLanguages(); - setLanguages(result); - } catch { - message.error('加载语言列表失败'); - } - setLoading(false); - }, []); - - useEffect(() => { - fetchLanguages(); - }, [fetchLanguages]); const handleToggle = async (record: LanguageInfo, checked: boolean) => { - try { - await updateLanguage(record.code, { is_active: checked }); - setLanguages((prev) => - prev.map((lang) => - lang.code === record.code ? { ...lang, is_active: checked } : lang, - ), - ); - message.success(checked ? '已启用' : '已禁用'); - } catch { - message.error('操作失败'); - } + await execute(() => updateLanguage(record.code, { is_active: checked, name: record.name }), checked ? '已启用' : '已禁用'); + refresh(); }; const openEdit = (lang: LanguageInfo) => { setEditingLang(lang); - form.setFieldsValue({ name: lang.name }); - setEditModalOpen(true); + setDrawerOpen(true); }; - const closeEdit = () => { - setEditModalOpen(false); - setEditingLang(null); - form.resetFields(); - }; - - const handleEditSubmit = async () => { + const handleEditSubmit = async (values: Record) => { if (!editingLang) return; - try { - const values = await form.validateFields(); - const updated = await updateLanguage(editingLang.code, { - is_active: editingLang.is_active, - name: values.name, - }); - setLanguages((prev) => - prev.map((lang) => - lang.code === editingLang.code ? updated : lang, - ), - ); - message.success('语言更新成功'); - closeEdit(); - } catch (err: unknown) { - if (err && typeof err === 'object' && 'errorFields' in err) return; - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '更新失败'; - message.error(errorMsg); - } + await execute( + () => updateLanguage(editingLang.code, { is_active: editingLang.is_active, name: values.name as string }), + '语言更新成功', + ); + setDrawerOpen(false); + setEditingLang(null); + refresh(); }; const columns = [ + { title: '语言代码', dataIndex: 'code', key: 'code', width: 160 }, + { title: '语言名称', dataIndex: 'name', key: 'name', width: 200 }, { - title: '语言代码', - dataIndex: 'code', - key: 'code', - width: 160, - }, - { - title: '语言名称', - dataIndex: 'name', - key: 'name', - width: 200, - }, - { - title: '状态', - dataIndex: 'is_active', - key: 'is_active', - width: 120, + title: '状态', dataIndex: 'is_active', key: 'is_active', width: 120, render: (is_active: boolean, record: LanguageInfo) => ( handleToggle(record, checked)} /> ), }, { - title: '操作', - key: 'actions', + title: '操作', key: 'actions', render: (_: unknown, record: LanguageInfo) => ( - + ), }, @@ -131,40 +68,33 @@ export default function LanguageManager() { return (
- - 语言管理 - + 语言管理 - -
- +
- { setDrawerOpen(false); setEditingLang(null); }} + onSubmit={handleEditSubmit} + initialValues={editingLang ? { name: editingLang.name } : undefined} + loading={false} + width={480} + columns={1} > -
- - - - - - - -
+ + + + + + + ); } diff --git a/apps/web/src/pages/settings/MenuConfig.tsx b/apps/web/src/pages/settings/MenuConfig.tsx index 062348c..06d737a 100644 --- a/apps/web/src/pages/settings/MenuConfig.tsx +++ b/apps/web/src/pages/settings/MenuConfig.tsx @@ -1,9 +1,7 @@ -import { useEffect, useState, useCallback } from 'react'; import { Table, Button, Space, - Modal, Form, Input, InputNumber, @@ -11,7 +9,6 @@ import { Switch, TreeSelect, Popconfirm, - message, Typography, Tag, } from 'antd'; @@ -24,190 +21,80 @@ import { type MenuInfo, type MenuItemReq, } from '../../api/menus'; - -// --- Types --- +import { useListData } from '../../hooks/useListData'; +import { useCrudDrawer } from '../../hooks/useCrudDrawer'; +import { DrawerForm } from '../../components/DrawerForm'; +import { useApiRequest } from '../../hooks/useApiRequest'; type MenuItem = MenuInfo; -// --- Helpers --- - -/** Convert nested menu tree back to flat list */ -function flattenMenuTree(tree: MenuItem[]): MenuItem[] { - const result: MenuItem[] = []; - const walk = (items: MenuItem[]) => { - for (const item of items) { - const { children, ...rest } = item; - result.push(rest as MenuItem); - if (children?.length) walk(children); - } - }; - walk(tree); - return result; -} - -/** Convert menu tree to TreeSelect data nodes */ -function toTreeSelectData( - items: MenuItem[], -): Array<{ title: string; value: string; children?: Array<{ title: string; value: string }> }> { +function toTreeSelectData(items: MenuItem[]): Array<{ title: string; value: string; children?: Array<{ title: string; value: string }> }> { return items.map((item) => ({ title: item.title, value: item.id, - children: - item.children && item.children.length > 0 - ? toTreeSelectData(item.children) - : undefined, + children: item.children?.length ? toTreeSelectData(item.children) : undefined, })); } -const menuTypeLabels: Record = { +const MENU_TYPE_LABELS: Record = { directory: { text: '目录', color: 'blue' }, menu: { text: '菜单', color: 'green' }, button: { text: '按钮', color: 'orange' }, }; -// --- Component --- - export default function MenuConfig() { - const [_menus, setMenus] = useState([]); - const [menuTree, setMenuTree] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [editMenu, setEditMenu] = useState(null); - const [form] = Form.useForm(); + const { data: menuTree, loading, refresh } = useListData(getMenus); - const fetchMenus = useCallback(async () => { - setLoading(true); - try { - const tree = await getMenus(); - setMenus(flattenMenuTree(tree)); - setMenuTree(tree); - } catch { - message.error('加载菜单失败'); - } - setLoading(false); - }, []); + const { execute } = useApiRequest(); - useEffect(() => { - fetchMenus(); - }, [fetchMenus]); - - const handleSubmit = async (values: MenuItemReq) => { - try { - if (editMenu) { - await updateMenu(editMenu.id, { ...values, version: editMenu.version }); - message.success('菜单更新成功'); - } else { - await createMenu(values); - message.success('菜单创建成功'); - } - closeModal(); - fetchMenus(); - } catch (err: unknown) { - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '操作失败'; - message.error(errorMsg); - } - }; + const drawer = useCrudDrawer({ + getId: (r) => r.id, + onCreate: async (values) => { + await createMenu(values as unknown as MenuItemReq); + }, + onUpdate: async (id, values) => { + await updateMenu(id, values as unknown as MenuItemReq & { version: number }); + }, + onSuccess: refresh, + }); const handleDelete = async (id: string, version: number) => { - try { - await deleteMenu(id, version); - message.success('菜单已删除'); - fetchMenus(); - } catch { - message.error('删除失败'); - } - }; - - const openCreate = () => { - setEditMenu(null); - form.resetFields(); - form.setFieldsValue({ - menu_type: 'menu', - sort_order: 0, - visible: true, - }); - setModalOpen(true); - }; - - const openEdit = (menu: MenuItem) => { - setEditMenu(menu); - form.setFieldsValue({ - parent_id: menu.parent_id || undefined, - title: menu.title, - path: menu.path, - icon: menu.icon, - menu_type: menu.menu_type, - sort_order: menu.sort_order, - visible: menu.visible, - permission: menu.permission, - }); - setModalOpen(true); - }; - - const closeModal = () => { - setModalOpen(false); - setEditMenu(null); - form.resetFields(); + await execute(() => deleteMenu(id, version), '菜单已删除'); + refresh(); }; const columns = [ { title: '标题', dataIndex: 'title', key: 'title', width: 200 }, + { title: '路径', dataIndex: 'path', key: 'path', ellipsis: true, render: (v?: string) => v || '-' }, + { title: '图标', dataIndex: 'icon', key: 'icon', width: 100, render: (v?: string) => v || '-' }, { - title: '路径', - dataIndex: 'path', - key: 'path', - ellipsis: true, - render: (v?: string) => v || '-', - }, - { - title: '图标', - dataIndex: 'icon', - key: 'icon', - width: 100, - render: (v?: string) => v || '-', - }, - { - title: '类型', - dataIndex: 'menu_type', - key: 'menu_type', - width: 90, + title: '类型', dataIndex: 'menu_type', key: 'menu_type', width: 90, render: (v: string) => { - const info = menuTypeLabels[v] ?? { text: v, color: 'default' }; + const info = MENU_TYPE_LABELS[v] ?? { text: v, color: 'default' }; return {info.text}; }, }, + { title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 }, { - title: '排序', - dataIndex: 'sort_order', - key: 'sort_order', - width: 80, + title: '可见', dataIndex: 'visible', key: 'visible', width: 80, + render: (v: boolean) => v ? : , }, { - title: '可见', - dataIndex: 'visible', - key: 'visible', - width: 80, - render: (v: boolean) => - v ? : , - }, - { - title: '操作', - key: 'actions', - width: 150, + title: '操作', key: 'actions', width: 150, render: (_: unknown, record: MenuItem) => ( - - handleDelete(record.id, record.version)} - > - + + handleDelete(record.id, record.version)}> + ), @@ -216,17 +103,13 @@ export default function MenuConfig() { return (
-
- - 菜单配置 - -
@@ -240,59 +123,50 @@ export default function MenuConfig() { indentSize={20} /> - form.submit()} - width={560} + -
- - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + +
); } diff --git a/apps/web/src/pages/settings/NumberingRules.tsx b/apps/web/src/pages/settings/NumberingRules.tsx index b1127ff..a7d5eed 100644 --- a/apps/web/src/pages/settings/NumberingRules.tsx +++ b/apps/web/src/pages/settings/NumberingRules.tsx @@ -1,15 +1,12 @@ -import { useEffect, useState, useCallback } from 'react'; import { Table, Button, Space, - Modal, Form, Input, InputNumber, Select, Popconfirm, - message, Typography, } from 'antd'; import { PlusOutlined, NumberOutlined } from '@ant-design/icons'; @@ -23,123 +20,57 @@ import { type CreateNumberingRuleRequest, type UpdateNumberingRuleRequest, } from '../../api/numberingRules'; - -// --- Types --- +import { useListData } from '../../hooks/useListData'; +import { useCrudDrawer } from '../../hooks/useCrudDrawer'; +import { DrawerForm } from '../../components/DrawerForm'; +import { useApiRequest } from '../../hooks/useApiRequest'; +import { message } from 'antd'; type NumberingRule = NumberingRuleInfo; -// --- Constants --- - -const resetCycleOptions = [ +const RESET_CYCLE_OPTIONS = [ { label: '不重置', value: 'never' }, { label: '每天', value: 'daily' }, { label: '每月', value: 'monthly' }, { label: '每年', value: 'yearly' }, ]; -const resetCycleLabels: Record = { +const RESET_CYCLE_LABELS: Record = { never: '不重置', daily: '每天', monthly: '每月', yearly: '每年', }; -// --- Component --- - export default function NumberingRules() { - const [rules, setRules] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [editRule, setEditRule] = useState(null); - const [form] = Form.useForm(); + const { data: rules, loading, refresh } = useListData(async () => { + const result = await listNumberingRules(); + return Array.isArray(result) ? result : result.data ?? []; + }); - const fetchRules = useCallback(async () => { - setLoading(true); - try { - const result = await listNumberingRules(); - setRules(Array.isArray(result) ? result : result.data ?? []); - } catch { - message.error('加载编号规则失败'); - } - setLoading(false); - }, []); + const { execute } = useApiRequest(); - useEffect(() => { - fetchRules(); - }, [fetchRules]); - - const handleSubmit = async (values: CreateNumberingRuleRequest) => { - try { - if (editRule) { - await updateNumberingRule(editRule.id, { ...values, version: editRule.version } as UpdateNumberingRuleRequest); - message.success('编号规则更新成功'); - } else { - await createNumberingRule(values); - message.success('编号规则创建成功'); - } - closeModal(); - fetchRules(); - } catch (err: unknown) { - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '操作失败'; - message.error(errorMsg); - } - }; + const drawer = useCrudDrawer({ + getId: (r) => r.id, + onCreate: async (values) => { + await createNumberingRule(values as unknown as CreateNumberingRuleRequest); + }, + onUpdate: async (id, values) => { + await updateNumberingRule(id, values as unknown as UpdateNumberingRuleRequest); + }, + onSuccess: refresh, + }); const handleDelete = async (id: string, version: number) => { - try { - await deleteNumberingRule(id, version); - message.success('编号规则已删除'); - fetchRules(); - } catch { - message.error('删除失败'); - } + await execute(() => deleteNumberingRule(id, version), '删除成功'); + refresh(); }; const handleGenerate = async (rule: NumberingRule) => { - try { + await execute(async () => { const result = await generateNumber(rule.id); message.success(`生成编号: ${result.number}`); - } catch (err: unknown) { - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '生成编号失败'; - message.error(errorMsg); - } - }; - - const openCreate = () => { - setEditRule(null); - form.resetFields(); - form.setFieldsValue({ - seq_length: 4, - seq_start: 1, - separator: '-', - reset_cycle: 'never', - }); - setModalOpen(true); - }; - - const openEdit = (rule: NumberingRule) => { - setEditRule(rule); - form.setFieldsValue({ - name: rule.name, - code: rule.code, - prefix: rule.prefix, - date_format: rule.date_format, - seq_length: rule.seq_length, - seq_start: rule.seq_start, - separator: rule.separator, - reset_cycle: rule.reset_cycle, - }); - setModalOpen(true); - }; - - const closeModal = () => { - setModalOpen(false); - setEditRule(null); - form.resetFields(); + }, undefined, '生成编号失败'); }; const columns = [ @@ -174,7 +105,7 @@ export default function NumberingRules() { dataIndex: 'reset_cycle', key: 'reset_cycle', width: 100, - render: (v: string) => resetCycleLabels[v] ?? v, + render: (v: string) => RESET_CYCLE_LABELS[v] ?? v, }, { title: '操作', @@ -188,16 +119,23 @@ export default function NumberingRules() { > 生成编号 - handleDelete(record.id, record.version)} > - + ), @@ -206,17 +144,17 @@ export default function NumberingRules() { return (
-
+
编号规则管理 -
@@ -229,56 +167,41 @@ export default function NumberingRules() { pagination={{ pageSize: 20 }} /> - form.submit()} - width={560} + -
- - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - +
- form.submit()} + open={drawerOpen} + onClose={() => { setDrawerOpen(false); setEditEntry(null); }} + onSubmit={handleSave} + initialValues={editEntry ? { setting_key: editEntry.key, setting_value: editEntry.value } : undefined} + loading={false} width={560} + columns={1} > - - - - - - - - - + + + + + + +
); } diff --git a/apps/web/src/pages/settings/ThemeSettings.tsx b/apps/web/src/pages/settings/ThemeSettings.tsx index d4795a7..0df4264 100644 --- a/apps/web/src/pages/settings/ThemeSettings.tsx +++ b/apps/web/src/pages/settings/ThemeSettings.tsx @@ -1,17 +1,13 @@ -import { useEffect, useState, useCallback } from 'react'; -import { Form, Input, Select, Button, ColorPicker, message, Typography, Divider } from 'antd'; -import { - getTheme, - updateTheme, -} from '../../api/themes'; +import { useEffect, useCallback } from 'react'; +import { Form, Input, Select, Button, ColorPicker, Typography, Divider } from 'antd'; +import { getTheme, updateTheme } from '../../api/themes'; +import { useApiRequest } from '../../hooks/useApiRequest'; export default function ThemeSettings() { const [form] = Form.useForm(); - const [, setLoading] = useState(false); - const [saving, setSaving] = useState(false); + const { execute, loading } = useApiRequest(); const fetchTheme = useCallback(async () => { - setLoading(true); try { const theme = await getTheme(); form.setFieldsValue({ @@ -25,67 +21,35 @@ export default function ThemeSettings() { }); } catch { form.setFieldsValue({ - primary_color: '#1677ff', - logo_url: '', - sidebar_style: 'light', - brand_name: '', - brand_slogan: '', - brand_features: '', - brand_copyright: '', + primary_color: '#1677ff', logo_url: '', sidebar_style: 'light', + brand_name: '', brand_slogan: '', brand_features: '', brand_copyright: '', }); } - setLoading(false); }, [form]); - useEffect(() => { - fetchTheme(); - }, [fetchTheme]); + useEffect(() => { fetchTheme(); }, [fetchTheme]); - const handleSave = async (values: { - primary_color: string; - logo_url: string; - sidebar_style: 'light' | 'dark'; - brand_name: string; - brand_slogan: string; - brand_features: string; - brand_copyright: string; - }) => { - setSaving(true); - try { + const handleSave = async (values: Record) => { + await execute(async () => { await updateTheme({ - primary_color: - typeof values.primary_color === 'string' - ? values.primary_color - : (values.primary_color as { toHexString?: () => string }).toHexString?.() ?? String(values.primary_color), - logo_url: values.logo_url, - sidebar_style: values.sidebar_style, - brand_name: values.brand_name || undefined, - brand_slogan: values.brand_slogan || undefined, - brand_features: values.brand_features || undefined, - brand_copyright: values.brand_copyright || undefined, + primary_color: typeof values.primary_color === 'string' + ? values.primary_color + : (values.primary_color as { toHexString?: () => string }).toHexString?.() ?? String(values.primary_color), + logo_url: values.logo_url as string, + sidebar_style: values.sidebar_style as 'light' | 'dark', + brand_name: (values.brand_name as string) || undefined, + brand_slogan: (values.brand_slogan as string) || undefined, + brand_features: (values.brand_features as string) || undefined, + brand_copyright: (values.brand_copyright as string) || undefined, }); - message.success('主题设置已保存'); - } catch (err: unknown) { - const errorMsg = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || '保存失败'; - message.error(errorMsg); - } - setSaving(false); + }, '主题设置已保存'); }; return (
- - 主题设置 - + 主题设置 -
+ @@ -93,12 +57,7 @@ export default function ThemeSettings() { - 品牌设置 @@ -117,9 +76,7 @@ export default function ThemeSettings() { - +