312 lines
8.9 KiB
TypeScript
312 lines
8.9 KiB
TypeScript
|
|
import {
|
|||
|
|
Button,
|
|||
|
|
Card,
|
|||
|
|
Drawer,
|
|||
|
|
Form,
|
|||
|
|
Input,
|
|||
|
|
message,
|
|||
|
|
Popconfirm,
|
|||
|
|
Space,
|
|||
|
|
Table,
|
|||
|
|
Tag,
|
|||
|
|
Typography,
|
|||
|
|
InputNumber,
|
|||
|
|
Row,
|
|||
|
|
Col,
|
|||
|
|
Select,
|
|||
|
|
Empty
|
|||
|
|
} from "antd";
|
|||
|
|
import { useEffect, useState, useMemo } from "react";
|
|||
|
|
import { createOrg, deleteOrg, listOrgs, updateOrg, listTenants } from "../api";
|
|||
|
|
import { usePermission } from "../hooks/usePermission";
|
|||
|
|
import {
|
|||
|
|
PlusOutlined,
|
|||
|
|
EditOutlined,
|
|||
|
|
DeleteOutlined,
|
|||
|
|
ApartmentOutlined,
|
|||
|
|
SearchOutlined,
|
|||
|
|
ReloadOutlined,
|
|||
|
|
ShopOutlined
|
|||
|
|
} from "@ant-design/icons";
|
|||
|
|
import type { SysOrg, SysTenant, OrgNode } from "../types";
|
|||
|
|
|
|||
|
|
const { Title, Text } = Typography;
|
|||
|
|
|
|||
|
|
function buildOrgTree(list: SysOrg[]): OrgNode[] {
|
|||
|
|
const map = new Map<number, OrgNode>();
|
|||
|
|
const roots: OrgNode[] = [];
|
|||
|
|
|
|||
|
|
list.forEach((item) => {
|
|||
|
|
map.set(item.id, { ...item, children: [] });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
map.forEach((node) => {
|
|||
|
|
if (node.parentId && map.has(node.parentId)) {
|
|||
|
|
map.get(node.parentId)!.children.push(node);
|
|||
|
|
} else {
|
|||
|
|
roots.push(node);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sortTree = (nodes: OrgNode[]) => {
|
|||
|
|
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|||
|
|
nodes.forEach(n => n.children && sortTree(n.children));
|
|||
|
|
};
|
|||
|
|
sortTree(roots);
|
|||
|
|
return roots;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function Orgs() {
|
|||
|
|
const { can } = usePermission();
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [data, setData] = useState<SysOrg[]>([]);
|
|||
|
|
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
|||
|
|
const [selectedTenantId, setSelectedTenantId] = useState<number | undefined>(undefined);
|
|||
|
|
|
|||
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|||
|
|
const [editing, setEditing] = useState<SysOrg | null>(null);
|
|||
|
|
const [form] = Form.useForm();
|
|||
|
|
|
|||
|
|
const loadTenants = async () => {
|
|||
|
|
try {
|
|||
|
|
const resp = await listTenants({ current: 1, size: 100 });
|
|||
|
|
const list = resp.records || [];
|
|||
|
|
setTenants(list);
|
|||
|
|
if (list.length > 0 && selectedTenantId === undefined) {
|
|||
|
|
setSelectedTenantId(list[0].id);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
message.error("加载租户列表失败");
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const loadOrgs = async () => {
|
|||
|
|
if (selectedTenantId === undefined) return;
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const list = await listOrgs(selectedTenantId);
|
|||
|
|
setData(list || []);
|
|||
|
|
} catch (e) {
|
|||
|
|
message.error("加载组织架构失败");
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadTenants();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadOrgs();
|
|||
|
|
}, [selectedTenantId]);
|
|||
|
|
|
|||
|
|
const treeData = useMemo(() => buildOrgTree(data), [data]);
|
|||
|
|
|
|||
|
|
const parentOptions = useMemo(() => {
|
|||
|
|
return data.map(o => ({ label: o.orgName, value: o.id }));
|
|||
|
|
}, [data]);
|
|||
|
|
|
|||
|
|
const openCreate = (parentId?: number) => {
|
|||
|
|
setEditing(null);
|
|||
|
|
form.resetFields();
|
|||
|
|
form.setFieldsValue({
|
|||
|
|
tenantId: selectedTenantId,
|
|||
|
|
parentId: parentId,
|
|||
|
|
status: 1,
|
|||
|
|
sortOrder: 0
|
|||
|
|
});
|
|||
|
|
setDrawerOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const openEdit = (record: SysOrg) => {
|
|||
|
|
setEditing(record);
|
|||
|
|
form.setFieldsValue(record);
|
|||
|
|
setDrawerOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (id: number) => {
|
|||
|
|
try {
|
|||
|
|
await deleteOrg(id);
|
|||
|
|
message.success("组织已删除");
|
|||
|
|
loadOrgs();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
message.error(e.message || "删除失败");
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const submit = async () => {
|
|||
|
|
try {
|
|||
|
|
const values = await form.validateFields();
|
|||
|
|
setSaving(true);
|
|||
|
|
if (editing) {
|
|||
|
|
await updateOrg(editing.id, values);
|
|||
|
|
message.success("更新成功");
|
|||
|
|
} else {
|
|||
|
|
await createOrg(values);
|
|||
|
|
message.success("创建成功");
|
|||
|
|
}
|
|||
|
|
setDrawerOpen(false);
|
|||
|
|
loadOrgs();
|
|||
|
|
} catch (e) {
|
|||
|
|
if (e instanceof Error && e.message) message.error(e.message);
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const columns = [
|
|||
|
|
{
|
|||
|
|
title: "组织名称",
|
|||
|
|
dataIndex: "orgName",
|
|||
|
|
key: "orgName",
|
|||
|
|
render: (text: string) => <Text strong>{text}</Text>
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: "组织编码",
|
|||
|
|
dataIndex: "orgCode",
|
|||
|
|
key: "orgCode",
|
|||
|
|
width: 150,
|
|||
|
|
render: (text: string) => <Tag className="tabular-nums">{text || "-"}</Tag>
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: "排序",
|
|||
|
|
dataIndex: "sortOrder",
|
|||
|
|
width: 100,
|
|||
|
|
className: "tabular-nums"
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: "状态",
|
|||
|
|
dataIndex: "status",
|
|||
|
|
width: 100,
|
|||
|
|
render: (s: number) => <Tag color={s === 1 ? "green" : "red"}>{s === 1 ? "启用" : "禁用"}</Tag>
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: "操作",
|
|||
|
|
key: "action",
|
|||
|
|
width: 180,
|
|||
|
|
render: (_: any, record: SysOrg) => (
|
|||
|
|
<Space>
|
|||
|
|
{can("sys_org:create") && (
|
|||
|
|
<Button type="link" size="small" onClick={() => openCreate(record.id)}>添加下级</Button>
|
|||
|
|
)}
|
|||
|
|
{can("sys_org:update") && (
|
|||
|
|
<Button type="text" size="small" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label="编辑组织" />
|
|||
|
|
)}
|
|||
|
|
{can("sys_org:delete") && (
|
|||
|
|
<Popconfirm title={`确定删除 "${record.orgName}" 吗?`} onConfirm={() => handleDelete(record.id)}>
|
|||
|
|
<Button type="text" size="small" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label="删除组织" />
|
|||
|
|
</Popconfirm>
|
|||
|
|
)}
|
|||
|
|
</Space>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="p-6">
|
|||
|
|
<div className="mb-6 flex justify-between items-end">
|
|||
|
|
<div>
|
|||
|
|
<Title level={4} className="mb-1">组织架构管理</Title>
|
|||
|
|
<Text type="secondary">维护企业内部部门层级关系,支持多租户架构隔离</Text>
|
|||
|
|
</div>
|
|||
|
|
{can("sys_org:create") && (
|
|||
|
|
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>
|
|||
|
|
新增根组织
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Card className="shadow-sm mb-4">
|
|||
|
|
<Space>
|
|||
|
|
<Text strong>所属租户:</Text>
|
|||
|
|
<Select
|
|||
|
|
style={{ width: 220 }}
|
|||
|
|
placeholder="切换租户查看架构"
|
|||
|
|
value={selectedTenantId}
|
|||
|
|
onChange={setSelectedTenantId}
|
|||
|
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
|||
|
|
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
|||
|
|
/>
|
|||
|
|
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>刷新</Button>
|
|||
|
|
</Space>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
|
|||
|
|
{selectedTenantId !== undefined ? (
|
|||
|
|
<Table
|
|||
|
|
rowKey="id"
|
|||
|
|
columns={columns}
|
|||
|
|
dataSource={treeData}
|
|||
|
|
loading={loading}
|
|||
|
|
pagination={false}
|
|||
|
|
size="middle"
|
|||
|
|
expandable={{ defaultExpandAllRows: true }}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="py-20 flex justify-center">
|
|||
|
|
<Empty description="请先选择一个租户以查看其组织架构" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Drawer
|
|||
|
|
title={
|
|||
|
|
<Space>
|
|||
|
|
<ApartmentOutlined aria-hidden="true" />
|
|||
|
|
<span>{editing ? "编辑组织节点" : "新增组织部门"}</span>
|
|||
|
|
</Space>
|
|||
|
|
}
|
|||
|
|
open={drawerOpen}
|
|||
|
|
onClose={() => setDrawerOpen(false)}
|
|||
|
|
width={420}
|
|||
|
|
destroyOnClose
|
|||
|
|
footer={
|
|||
|
|
<div className="flex justify-end gap-2 p-2">
|
|||
|
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
|||
|
|
<Button type="primary" loading={saving} onClick={submit}>确认提交</Button>
|
|||
|
|
</div>
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<Form form={form} layout="vertical">
|
|||
|
|
<Form.Item label="所属租户" name="tenantId" rules={[{ required: true }]}>
|
|||
|
|
<Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} />
|
|||
|
|
</Form.Item>
|
|||
|
|
|
|||
|
|
<Form.Item label="上级部门" name="parentId">
|
|||
|
|
<Select
|
|||
|
|
placeholder="顶级部门"
|
|||
|
|
allowClear
|
|||
|
|
showSearch
|
|||
|
|
optionFilterProp="label"
|
|||
|
|
options={parentOptions}
|
|||
|
|
/>
|
|||
|
|
</Form.Item>
|
|||
|
|
|
|||
|
|
<Form.Item label="部门名称" name="orgName" rules={[{ required: true, message: "请输入部门/组织名称" }]}>
|
|||
|
|
<Input placeholder="例如:技术部、财务处…" />
|
|||
|
|
</Form.Item>
|
|||
|
|
|
|||
|
|
<Form.Item label="部门编码" name="orgCode">
|
|||
|
|
<Input placeholder="例如:DEPT_TECH" className="tabular-nums" />
|
|||
|
|
</Form.Item>
|
|||
|
|
|
|||
|
|
<Row gutter={16}>
|
|||
|
|
<Col span={12}>
|
|||
|
|
<Form.Item label="显示排序" name="sortOrder" initialValue={0}>
|
|||
|
|
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
|
|||
|
|
</Form.Item>
|
|||
|
|
</Col>
|
|||
|
|
<Col span={12}>
|
|||
|
|
<Form.Item label="状态" name="status" initialValue={1}>
|
|||
|
|
<Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} />
|
|||
|
|
</Form.Item>
|
|||
|
|
</Col>
|
|||
|
|
</Row>
|
|||
|
|
</Form>
|
|||
|
|
</Drawer>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|