340 lines
9.9 KiB
TypeScript
340 lines
9.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 { useTranslation } from "react-i18next";
|
||
import { createOrg, deleteOrg, listOrgs, updateOrg, listTenants } from "../api";
|
||
import { usePermission } from "../hooks/usePermission";
|
||
import {
|
||
PlusOutlined,
|
||
EditOutlined,
|
||
DeleteOutlined,
|
||
ApartmentOutlined,
|
||
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 { t } = useTranslation();
|
||
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);
|
||
|
||
// Platform admin check
|
||
const isPlatformMode = useMemo(() => {
|
||
const profileStr = sessionStorage.getItem("userProfile");
|
||
if (profileStr) {
|
||
const profile = JSON.parse(profileStr);
|
||
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
||
}
|
||
return false;
|
||
}, []);
|
||
|
||
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
||
|
||
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 (!isPlatformMode) {
|
||
setSelectedTenantId(activeTenantId);
|
||
} else if (list.length > 0 && selectedTenantId === undefined) {
|
||
setSelectedTenantId(list[0].id);
|
||
}
|
||
} catch (e) {
|
||
message.error(t('common.error'));
|
||
}
|
||
};
|
||
|
||
const loadOrgs = async () => {
|
||
if (selectedTenantId === undefined) return;
|
||
setLoading(true);
|
||
try {
|
||
const list = await listOrgs(selectedTenantId);
|
||
setData(list || []);
|
||
} catch (e) {
|
||
message.error(t('common.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(t('common.success'));
|
||
loadOrgs();
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.error'));
|
||
}
|
||
};
|
||
|
||
const submit = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
if (editing) {
|
||
await updateOrg(editing.id, values);
|
||
message.success(t('common.success'));
|
||
} else {
|
||
await createOrg(values);
|
||
message.success(t('common.success'));
|
||
}
|
||
setDrawerOpen(false);
|
||
loadOrgs();
|
||
} catch (e) {
|
||
if (e instanceof Error && e.message) message.error(e.message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const columns = [
|
||
{
|
||
title: t('orgs.orgName'),
|
||
dataIndex: "orgName",
|
||
key: "orgName",
|
||
render: (text: string) => <Text strong>{text}</Text>
|
||
},
|
||
{
|
||
title: t('orgs.orgCode'),
|
||
dataIndex: "orgCode",
|
||
key: "orgCode",
|
||
width: 150,
|
||
render: (text: string) => <Tag className="tabular-nums">{text || "-"}</Tag>
|
||
},
|
||
{
|
||
title: t('orgs.sort'),
|
||
dataIndex: "sortOrder",
|
||
width: 100,
|
||
className: "tabular-nums"
|
||
},
|
||
{
|
||
title: t('common.status'),
|
||
dataIndex: "status",
|
||
width: 100,
|
||
render: (s: number) => <Tag color={s === 1 ? "green" : "red"}>{s === 1 ? "启用" : "禁用"}</Tag>
|
||
},
|
||
{
|
||
title: t('common.action'),
|
||
key: "action",
|
||
width: 180,
|
||
render: (_: any, record: SysOrg) => (
|
||
<Space>
|
||
{can("sys:org:create") && (
|
||
<Button type="link" size="small" onClick={() => openCreate(record.id)}>{t('orgs.addSub')}</Button>
|
||
)}
|
||
{can("sys:org:update") && (
|
||
<Button type="text" size="small" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t('common.edit')} />
|
||
)}
|
||
{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={t('common.delete')} />
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
)
|
||
}
|
||
];
|
||
|
||
return (
|
||
<div className="p-6">
|
||
<div className="mb-6 flex justify-between items-end">
|
||
<div>
|
||
<Title level={4} className="mb-1">{t('orgs.title')}</Title>
|
||
<Text type="secondary">{t('orgs.subtitle')}</Text>
|
||
</div>
|
||
{can("sys:org:create") && (
|
||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>
|
||
{t('orgs.createRoot')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{isPlatformMode && (
|
||
<Card className="shadow-sm mb-4">
|
||
<Space>
|
||
<Text strong>{t('users.tenant')}:</Text>
|
||
<Select
|
||
style={{ width: 220 }}
|
||
placeholder={t('orgs.selectTenant')}
|
||
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}>{t('common.refresh')}</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={t('orgs.selectTenant')} />
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
<Drawer
|
||
title={
|
||
<Space>
|
||
<ApartmentOutlined aria-hidden="true" />
|
||
<span>{editing ? t('orgs.drawerTitleEdit') : t('orgs.drawerTitleCreate')}</span>
|
||
</Space>
|
||
}
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
width={420}
|
||
destroyOnClose
|
||
footer={
|
||
<div className="flex justify-end gap-2 p-2">
|
||
<Button onClick={() => setDrawerOpen(false)}>{t('common.cancel')}</Button>
|
||
<Button type="primary" loading={saving} onClick={submit}>{t('common.confirm')}</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
label={t('users.tenant')}
|
||
name="tenantId"
|
||
rules={[{ required: true }]}
|
||
hidden={!isPlatformMode}
|
||
>
|
||
<Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} />
|
||
</Form.Item>
|
||
{!isPlatformMode && (
|
||
<Form.Item label={t('users.tenant')}>
|
||
<Input value={tenants.find(t => t.id === activeTenantId)?.tenantName || "当前租户"} disabled />
|
||
</Form.Item>
|
||
)}
|
||
|
||
<Form.Item label={t('orgs.parentOrg')} name="parentId">
|
||
<Select
|
||
placeholder={t('orgs.rootOrg')}
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={parentOptions}
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item label={t('orgs.orgName')} name="orgName" rules={[{ required: true, message: t('orgs.orgName') }]}>
|
||
<Input placeholder="例如:技术部、财务处…" />
|
||
</Form.Item>
|
||
|
||
<Form.Item label={t('orgs.orgCode')} name="orgCode">
|
||
<Input placeholder="例如:DEPT_TECH" className="tabular-nums" />
|
||
</Form.Item>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item label={t('dicts.sort')} name="sortOrder" initialValue={0}>
|
||
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item label={t('common.status')} name="status" initialValue={1}>
|
||
<Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
</Form>
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|