- Run cargo fmt on all Rust crates for consistent formatting - Update CLAUDE.md with WASM plugin commands and dev.ps1 instructions - Update wiki: add WASM plugin architecture, rewrite dev environment docs - Minor frontend cleanup (unused imports)
624 lines
18 KiB
TypeScript
624 lines
18 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Tree,
|
|
Button,
|
|
Space,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Table,
|
|
Popconfirm,
|
|
message,
|
|
Empty,
|
|
Tag,
|
|
theme,
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined,
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
ApartmentOutlined,
|
|
} from '@ant-design/icons';
|
|
import type { DataNode } from 'antd/es/tree';
|
|
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 { token } = theme.useToken();
|
|
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
|
|
|
const cardStyle = {
|
|
background: isDark ? '#111827' : '#FFFFFF',
|
|
borderRadius: 12,
|
|
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
|
};
|
|
|
|
// --- Org tree state ---
|
|
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
|
|
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
|
|
const [, setLoading] = useState(false);
|
|
|
|
// --- Department tree state ---
|
|
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
|
|
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
|
|
|
|
// --- Position list state ---
|
|
const [positions, setPositions] = useState<PositionInfo[]>([]);
|
|
|
|
// --- Modal state ---
|
|
const [orgModalOpen, setOrgModalOpen] = useState(false);
|
|
const [deptModalOpen, setDeptModalOpen] = useState(false);
|
|
const [positionModalOpen, setPositionModalOpen] = useState(false);
|
|
const [editOrg, setEditOrg] = useState<OrganizationInfo | null>(null);
|
|
|
|
const [orgForm] = Form.useForm();
|
|
const [deptForm] = Form.useForm();
|
|
const [positionForm] = Form.useForm();
|
|
|
|
// --- 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([]);
|
|
}
|
|
}
|
|
} catch {
|
|
message.error('加载组织树失败');
|
|
}
|
|
setLoading(false);
|
|
}, [selectedOrg]);
|
|
|
|
useEffect(() => {
|
|
fetchOrgTree();
|
|
}, [fetchOrgTree]);
|
|
|
|
// --- Fetch dept tree when org selected ---
|
|
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 {
|
|
message.error('加载部门树失败');
|
|
}
|
|
}, [selectedOrg, selectedDept]);
|
|
|
|
useEffect(() => {
|
|
fetchDeptTree();
|
|
}, [fetchDeptTree]);
|
|
|
|
// --- Fetch positions when dept selected ---
|
|
const fetchPositions = useCallback(async () => {
|
|
if (!selectedDept) return;
|
|
try {
|
|
const list = await listPositions(selectedDept.id);
|
|
setPositions(list);
|
|
} catch {
|
|
message.error('加载岗位列表失败');
|
|
}
|
|
}, [selectedDept]);
|
|
|
|
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) {
|
|
const errorMsg =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
|
message.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const handleDeleteOrg = async (id: string) => {
|
|
try {
|
|
await deleteOrg(id);
|
|
message.success('组织已删除');
|
|
setSelectedOrg(null);
|
|
setDeptTree([]);
|
|
setPositions([]);
|
|
fetchOrgTree();
|
|
} catch (err: unknown) {
|
|
const errorMsg =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
|
|
message.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
// --- Dept handlers ---
|
|
const handleCreateDept = async (values: {
|
|
name: string;
|
|
code?: string;
|
|
sort_order?: number;
|
|
}) => {
|
|
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) {
|
|
const errorMsg =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
|
message.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const handleDeleteDept = async (id: string) => {
|
|
try {
|
|
await deleteDept(id);
|
|
message.success('部门已删除');
|
|
setSelectedDept(null);
|
|
setPositions([]);
|
|
fetchDeptTree();
|
|
} catch (err: unknown) {
|
|
const errorMsg =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
|
|
message.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
// --- Position handlers ---
|
|
const handleCreatePosition = async (values: {
|
|
name: string;
|
|
code?: string;
|
|
level?: number;
|
|
sort_order?: number;
|
|
}) => {
|
|
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) {
|
|
const errorMsg =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
|
message.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const handleDeletePosition = async (id: string) => {
|
|
try {
|
|
await deletePosition(id);
|
|
message.success('岗位已删除');
|
|
fetchPositions();
|
|
} catch {
|
|
message.error('删除失败');
|
|
}
|
|
};
|
|
|
|
// --- 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 ? '#1E293B' : '#EEF2FF',
|
|
border: 'none',
|
|
color: '#4F46E5',
|
|
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 ? '#1E293B' : '#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;
|
|
}
|
|
const org = findOrgInTree(orgTree, selectedKeys[0] as string);
|
|
setSelectedOrg(org);
|
|
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);
|
|
};
|
|
|
|
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: '#4F46E5' }} />
|
|
组织架构管理
|
|
</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 ? '#1E293B' : '#F1F5F9'}`,
|
|
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={() => {
|
|
setEditOrg(null);
|
|
orgForm.resetFields();
|
|
setOrgModalOpen(true);
|
|
}}
|
|
/>
|
|
{selectedOrg && (
|
|
<>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
icon={<EditOutlined />}
|
|
onClick={() => {
|
|
setEditOrg(selectedOrg);
|
|
orgForm.setFieldsValue({
|
|
name: selectedOrg.name,
|
|
code: selectedOrg.code,
|
|
sort_order: selectedOrg.sort_order,
|
|
});
|
|
setOrgModalOpen(true);
|
|
}}
|
|
/>
|
|
<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 ? '#1E293B' : '#F1F5F9'}`,
|
|
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={() => {
|
|
deptForm.resetFields();
|
|
setDeptModalOpen(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 ? '#1E293B' : '#F1F5F9'}`,
|
|
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={() => {
|
|
positionForm.resetFields();
|
|
setPositionModalOpen(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 Modal */}
|
|
<Modal
|
|
title={editOrg ? '编辑组织' : selectedOrg ? `在 ${selectedOrg.name} 下新建子组织` : '新建根组织'}
|
|
open={orgModalOpen}
|
|
onCancel={() => {
|
|
setOrgModalOpen(false);
|
|
setEditOrg(null);
|
|
}}
|
|
onOk={() => orgForm.submit()}
|
|
>
|
|
<Form form={orgForm} onFinish={handleCreateOrg} layout="vertical" style={{ marginTop: 16 }}>
|
|
<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>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Dept Modal */}
|
|
<Modal
|
|
title={
|
|
selectedDept
|
|
? `在 ${selectedDept.name} 下新建子部门`
|
|
: `在 ${selectedOrg?.name} 下新建部门`
|
|
}
|
|
open={deptModalOpen}
|
|
onCancel={() => setDeptModalOpen(false)}
|
|
onOk={() => deptForm.submit()}
|
|
>
|
|
<Form form={deptForm} onFinish={handleCreateDept} layout="vertical" style={{ marginTop: 16 }}>
|
|
<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>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Position Modal */}
|
|
<Modal
|
|
title={`在 ${selectedDept?.name} 下新建岗位`}
|
|
open={positionModalOpen}
|
|
onCancel={() => setPositionModalOpen(false)}
|
|
onOk={() => positionForm.submit()}
|
|
>
|
|
<Form form={positionForm} onFinish={handleCreatePosition} layout="vertical" style={{ marginTop: 16 }}>
|
|
<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>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
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;
|
|
}
|