Files
hms/apps/web/src/pages/Organizations.tsx
iven d436888ca5
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
refactor(web): 系统设置模块页面表单一致性重构
- 新增 useCrudDrawer hook 封装 CRUD Drawer 通用模式(状态管理/提交/错误处理)
- 新增 useListData hook 封装非分页列表数据获取
- 11 个页面统一迁移到 DrawerForm + 共享 hooks,消除重复代码
- 错误处理统一使用 useApiRequest.execute(),移除内联 try-catch
- Modal 全部替换为 DrawerForm,保持 UI 一致性
- 净减少 ~1300 行代码(858 增 / 2136 删)
2026-05-04 11:57:38 +08:00

372 lines
14 KiB
TypeScript

import { useState, useCallback, useEffect, useRef } from 'react';
import {
Tree,
Button,
Space,
Form,
Input,
InputNumber,
Table,
Popconfirm,
Empty,
Tag,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import { useThemeMode } from '../hooks/useThemeMode';
import { DrawerForm } from '../components/DrawerForm';
import { useCrudDrawer } from '../hooks/useCrudDrawer';
import { useApiRequest } from '../hooks/useApiRequest';
import {
listOrgTree,
createOrg,
updateOrg,
deleteOrg,
listDeptTree,
createDept,
deleteDept,
listPositions,
createPosition,
deletePosition,
type OrganizationInfo,
type DepartmentInfo,
type PositionInfo,
} from '../api/orgs';
export default function Organizations() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
};
// --- Org tree state ---
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
// --- Department tree state ---
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
// --- Position list state ---
const [positions, setPositions] = useState<PositionInfo[]>([]);
// --- Ref for drawer onSuccess callback (avoids before-declaration issue) ---
const refreshOrgTreeRef = useRef<() => void>(() => {});
// --- Fetch org tree ---
const fetchOrgTree = useCallback(async () => {
try {
const tree = await listOrgTree();
setOrgTree(tree);
if (selectedOrg) {
const stillExists = findOrgInTree(tree, selectedOrg.id);
if (!stillExists) { setSelectedOrg(null); setDeptTree([]); setPositions([]); }
}
} catch { /* silent */ }
}, [selectedOrg]);
refreshOrgTreeRef.current = () => { fetchOrgTree(); };
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<OrganizationInfo>({
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 {
const tree = await listDeptTree(selectedOrg.id);
setDeptTree(tree);
if (selectedDept) {
const stillExists = findDeptInTree(tree, selectedDept.id);
if (!stillExists) { setSelectedDept(null); setPositions([]); }
}
} catch { /* silent */ }
}, [selectedOrg, selectedDept]);
useEffect(() => { fetchDeptTree(); }, [fetchDeptTree]);
// --- Fetch positions ---
const fetchPositions = useCallback(async () => {
if (!selectedDept) return;
try {
setPositions(await listPositions(selectedDept.id));
} catch { /* silent */ }
}, [selectedDept]);
useEffect(() => { fetchPositions(); }, [fetchPositions]);
// --- Org handlers ---
const handleDeleteOrg = async (id: string) => {
await execute(() => deleteOrg(id), '组织已删除');
setSelectedOrg(null); setDeptTree([]); setPositions([]);
fetchOrgTree();
};
// --- Dept handlers ---
const handleCreateDept = async (values: Record<string, unknown>) => {
if (!selectedOrg) return;
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) => {
await execute(() => deleteDept(id), '部门已删除');
setSelectedDept(null); setPositions([]);
fetchDeptTree();
};
// --- Position handlers ---
const handleCreatePosition = async (values: Record<string, unknown>) => {
if (!selectedDept) return;
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) => {
await execute(() => deletePosition(id), '岗位已删除');
fetchPositions();
};
// --- Tree node converters ---
const convertOrgTree = (items: OrganizationInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: <span>{item.name} {item.code && <Tag style={{ marginLeft: 4, background: isDark ? '#0f172a' : '#eff6ff', border: 'none', color: '#2563eb', fontSize: 11 }}>{item.code}</Tag>}</span>,
children: convertOrgTree(item.children),
}));
const convertDeptTree = (items: DepartmentInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: <span>{item.name} {item.code && <Tag style={{ marginLeft: 4, background: isDark ? '#0f172a' : '#ECFDF5', border: 'none', color: '#059669', fontSize: 11 }}>{item.code}</Tag>}</span>,
children: convertDeptTree(item.children),
}));
const onSelectOrg = (selectedKeys: React.Key[]) => {
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; }
setSelectedDept(findDeptInTree(deptTree, selectedKeys[0] as string));
};
const positionColumns = [
{ title: '岗位名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code', render: (v?: string) => v || '-' },
{ title: '级别', dataIndex: 'level', key: 'level' },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
{
title: '操作', key: 'actions',
render: (_: unknown, record: PositionInfo) => (
<Popconfirm title="确定删除此岗位?" onConfirm={() => handleDeletePosition(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
},
];
return (
<div>
<div className="erp-page-header">
<div>
<h4><ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} /></h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
{/* 左栏:组织树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span>
<Space size={4}>
<Button size="small" type="text" icon={<PlusOutlined />} onClick={() => orgDrawer.openCreate()} />
{selectedOrg && (
<>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => orgDrawer.openEdit(selectedOrg, (r) => ({
name: r.name, code: r.code, sort_order: r.sort_order,
}))} />
<Popconfirm title="确定删除此组织?" onConfirm={() => handleDeleteOrg(selectedOrg.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</>
)}
</Space>
</div>
<div style={{ padding: 12 }}>
{orgTree.length > 0 ? (
<Tree showLine defaultExpandAll treeData={convertOrgTree(orgTree)} onSelect={onSelectOrg} selectedKeys={selectedOrg ? [selectedOrg.id] : []} />
) : <Empty description="暂无组织" />}
</div>
</div>
{/* 中栏:部门树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{selectedOrg ? `${selectedOrg.name} · 部门` : '部门'}</span>
{selectedOrg && (
<Space size={4}>
<Button size="small" type="text" icon={<PlusOutlined />} onClick={() => setDeptDrawerOpen(true)} />
{selectedDept && (
<Popconfirm title="确定删除此部门?" onConfirm={() => handleDeleteDept(selectedDept.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
)}
</div>
<div style={{ padding: 12 }}>
{selectedOrg ? (
deptTree.length > 0 ? (
<Tree showLine defaultExpandAll treeData={convertDeptTree(deptTree)} onSelect={onSelectDept} selectedKeys={selectedDept ? [selectedDept.id] : []} />
) : <Empty description="暂无部门" />
) : <Empty description="请先选择组织" />}
</div>
</div>
{/* 右栏:岗位表 */}
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{selectedDept ? `${selectedDept.name} · 岗位` : '岗位'}</span>
{selectedDept && (
<Button size="small" type="text" icon={<PlusOutlined />} onClick={() => setPositionDrawerOpen(true)}></Button>
)}
</div>
<div style={{ padding: '0 4px' }}>
{selectedDept ? (
<Table columns={positionColumns} dataSource={positions} rowKey="id" size="small" pagination={false} />
) : <div style={{ padding: 24 }}><Empty description="请先选择部门" /></div>}
</div>
</div>
</div>
{/* Org Drawer */}
<DrawerForm
title={orgDrawer.editingRecord ? '编辑组织' : selectedOrg ? `${selectedOrg.name} 下新建子组织` : '新建根组织'}
open={orgDrawer.open}
onClose={orgDrawer.close}
onSubmit={orgDrawer.handleSubmit}
initialValues={orgDrawer.initialValues}
loading={orgDrawer.loading}
width={480}
columns={1}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入组织名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码"><Input /></Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</DrawerForm>
{/* Dept Drawer */}
<DrawerForm
title={selectedDept ? `${selectedDept.name} 下新建子部门` : `${selectedOrg?.name} 下新建部门`}
open={deptDrawerOpen}
onClose={() => setDeptDrawerOpen(false)}
onSubmit={handleCreateDept}
initialValues={{ sort_order: 0 }}
loading={false}
width={480}
columns={1}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入部门名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码"><Input /></Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</DrawerForm>
{/* Position Drawer */}
<DrawerForm
title={`${selectedDept?.name} 下新建岗位`}
open={positionDrawerOpen}
onClose={() => setPositionDrawerOpen(false)}
onSubmit={handleCreatePosition}
initialValues={{ level: 1, sort_order: 0 }}
loading={false}
width={480}
columns={1}
>
<Form.Item name="name" label="岗位名称" rules={[{ required: true, message: '请输入岗位名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码"><Input /></Form.Item>
<Form.Item name="level" label="级别" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</DrawerForm>
</div>
);
}
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);
if (found) return found;
}
return 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);
if (found) return found;
}
return null;
}