448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import {
|
|
Button,
|
|
Card,
|
|
Drawer,
|
|
Form,
|
|
Input,
|
|
message,
|
|
Popconfirm,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
Tree,
|
|
Row,
|
|
Col,
|
|
Tabs,
|
|
Empty
|
|
} from "antd";
|
|
import type { DataNode } from "antd/es/tree";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
createRole,
|
|
listPermissions,
|
|
listRolePermissions,
|
|
listRoles,
|
|
saveRolePermissions,
|
|
updateRole,
|
|
deleteRole,
|
|
fetchUsersByRoleId
|
|
} from "../api";
|
|
import type { SysPermission, SysRole, SysUser } from "../types";
|
|
import { usePermission } from "../hooks/usePermission";
|
|
import {
|
|
EditOutlined,
|
|
PlusOutlined,
|
|
SafetyCertificateOutlined,
|
|
SearchOutlined,
|
|
DeleteOutlined,
|
|
KeyOutlined,
|
|
UserOutlined,
|
|
SaveOutlined
|
|
} from "@ant-design/icons";
|
|
import "./Roles.css";
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
const DEFAULT_STATUS = 1;
|
|
|
|
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
|
|
|
|
const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
|
|
if (!list || list.length === 0) return [];
|
|
const active = list.filter((p) => p.status !== 0);
|
|
const map = new Map<number, PermissionNode>();
|
|
const roots: PermissionNode[] = [];
|
|
|
|
active.forEach((item) => {
|
|
map.set(item.permId, { ...item, key: item.permId, children: [] });
|
|
});
|
|
|
|
map.forEach((node) => {
|
|
if (node.parentId && node.parentId !== 0) {
|
|
const parent = map.get(node.parentId);
|
|
if (parent) {
|
|
parent.children!.push(node);
|
|
}
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
|
|
const sortNodes = (nodes: PermissionNode[]) => {
|
|
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
nodes.forEach((n) => n.children && sortNodes(n.children));
|
|
};
|
|
sortNodes(roots);
|
|
return roots;
|
|
};
|
|
|
|
const toTreeData = (nodes: PermissionNode[]): DataNode[] =>
|
|
nodes.map((node) => ({
|
|
key: node.permId,
|
|
title: (
|
|
<span className="role-permission-node">
|
|
<span>{node.name}</span>
|
|
{node.permType === "button" && <Tag color="blue" style={{ marginLeft: 8 }}>按钮</Tag>}
|
|
</span>
|
|
),
|
|
children: node.children && node.children.length > 0 ? toTreeData(node.children) : undefined
|
|
}));
|
|
|
|
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
|
|
|
|
export default function Roles() {
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [data, setData] = useState<SysRole[]>([]);
|
|
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
|
const [selectedRole, setSelectedRole] = useState<SysRole | null>(null);
|
|
|
|
// Right side states
|
|
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
|
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
|
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
|
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
|
|
|
// Search
|
|
const [searchText, setSearchText] = useState("");
|
|
|
|
// Drawer (Only for Add/Edit basic info)
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [editing, setEditing] = useState<SysRole | null>(null);
|
|
const [form] = Form.useForm();
|
|
|
|
const { can } = usePermission();
|
|
|
|
const permissionTreeData = useMemo(
|
|
() => toTreeData(buildPermissionTree(permissions)),
|
|
[permissions]
|
|
);
|
|
|
|
const loadPermissions = async () => {
|
|
try {
|
|
const list = await listPermissions();
|
|
setPermissions(list || []);
|
|
} catch (e) {
|
|
setPermissions([]);
|
|
}
|
|
};
|
|
|
|
const loadRoles = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const list = await listRoles();
|
|
const roles = list || [];
|
|
setData(roles);
|
|
if (roles.length > 0 && !selectedRole) {
|
|
selectRole(roles[0]);
|
|
} else if (selectedRole) {
|
|
const updated = roles.find(r => r.roleId === selectedRole.roleId);
|
|
if (updated) setSelectedRole(updated);
|
|
}
|
|
await loadPermissions();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const selectRole = async (role: SysRole) => {
|
|
setSelectedRole(role);
|
|
try {
|
|
// Load permissions for this role
|
|
const ids = await listRolePermissions(role.roleId);
|
|
const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
|
|
|
|
// Filter out parents for Tree回显
|
|
const leafIds = normalized.filter(id => {
|
|
return !permissions.some(p => p.parentId === id);
|
|
});
|
|
setSelectedPermIds(leafIds);
|
|
setHalfCheckedIds([]);
|
|
|
|
// Load users for this role
|
|
setLoadingUsers(true);
|
|
const users = await fetchUsersByRoleId(role.roleId);
|
|
setRoleUsers(users || []);
|
|
} catch (e) {
|
|
message.error("加载角色详情失败");
|
|
} finally {
|
|
setLoadingUsers(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadRoles();
|
|
}, []);
|
|
|
|
// Reload role detail if permissions list loaded later
|
|
useEffect(() => {
|
|
if (selectedRole && permissions.length > 0) {
|
|
// We don't want to infinite loop, but we need to ensure leafIds are correct
|
|
// after permissions are loaded.
|
|
const leafIds = selectedPermIds.filter(id => {
|
|
return !permissions.some(p => p.parentId === id);
|
|
});
|
|
if (leafIds.length !== selectedPermIds.length) {
|
|
setSelectedPermIds(leafIds);
|
|
}
|
|
}
|
|
}, [permissions]);
|
|
|
|
const filteredData = useMemo(() => {
|
|
if (!searchText) return data;
|
|
const lower = searchText.toLowerCase();
|
|
return data.filter(r =>
|
|
r.roleName.toLowerCase().includes(lower) ||
|
|
r.roleCode.toLowerCase().includes(lower)
|
|
);
|
|
}, [data, searchText]);
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({ status: 1 });
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
const openEditBasic = (e: React.MouseEvent, record: SysRole) => {
|
|
e.stopPropagation();
|
|
setEditing(record);
|
|
form.setFieldsValue(record);
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
const handleRemove = async (e: React.MouseEvent, id: number) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await deleteRole(id);
|
|
message.success("角色已删除");
|
|
if (selectedRole?.roleId === id) setSelectedRole(null);
|
|
loadRoles();
|
|
} catch (e) {
|
|
message.error("删除失败");
|
|
}
|
|
};
|
|
|
|
const submitBasic = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
const payload: Partial<SysRole> = {
|
|
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
|
|
roleName: values.roleName,
|
|
remark: values.remark,
|
|
status: values.status ?? DEFAULT_STATUS
|
|
};
|
|
|
|
if (editing) {
|
|
await updateRole(editing.roleId, payload);
|
|
message.success("角色已更新");
|
|
} else {
|
|
await createRole(payload);
|
|
message.success("角色已创建");
|
|
}
|
|
|
|
setDrawerOpen(false);
|
|
loadRoles();
|
|
} catch (e) {
|
|
if (e instanceof Error && e.message) message.error(e.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const savePermissions = async () => {
|
|
if (!selectedRole) return;
|
|
setSaving(true);
|
|
try {
|
|
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
|
|
await saveRolePermissions(selectedRole.roleId, allPermIds);
|
|
message.success("权限已保存并生效");
|
|
} catch (e) {
|
|
message.error("保存权限失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="roles-page-v2">
|
|
<Row gutter={24} style={{ height: 'calc(100vh - 120px)' }}>
|
|
{/* Left: Role List */}
|
|
<Col span={8} style={{ height: '100%' }}>
|
|
<Card
|
|
title="系统角色"
|
|
className="full-height-card"
|
|
extra={can("sys_role:create") && <Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>新增</Button>}
|
|
>
|
|
<div className="mb-4">
|
|
<Input
|
|
placeholder="搜索角色..."
|
|
prefix={<SearchOutlined />}
|
|
value={searchText}
|
|
onChange={e => setSearchText(e.target.value)}
|
|
allowClear
|
|
/>
|
|
</div>
|
|
<div className="role-list-container">
|
|
<Table
|
|
rowKey="roleId"
|
|
showHeader={false}
|
|
dataSource={filteredData}
|
|
loading={loading}
|
|
pagination={false}
|
|
onRow={(record) => ({
|
|
onClick: () => selectRole(record),
|
|
className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}`
|
|
})}
|
|
columns={[
|
|
{
|
|
title: '角色',
|
|
render: (_, record) => (
|
|
<div className="role-item-content">
|
|
<div className="role-item-main">
|
|
<div className="role-item-name">{record.roleName}</div>
|
|
<div className="role-item-code">{record.roleCode}</div>
|
|
</div>
|
|
<div className="role-item-actions">
|
|
{can("sys_role:update") && <Button type="text" size="small" icon={<EditOutlined />} onClick={e => openEditBasic(e, record)} />}
|
|
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
|
|
<Popconfirm title="删除角色?" onConfirm={e => handleRemove(e!, record.roleId)}>
|
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()} />
|
|
</Popconfirm>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Right: Detail Tabs */}
|
|
<Col span={16} style={{ height: '100%' }}>
|
|
{selectedRole ? (
|
|
<Card
|
|
className="full-height-card"
|
|
title={
|
|
<Space>
|
|
<SafetyCertificateOutlined style={{ color: '#1890ff' }} />
|
|
<span>{selectedRole.roleName}</span>
|
|
<Tag color="blue">{selectedRole.roleCode}</Tag>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined />}
|
|
loading={saving}
|
|
onClick={savePermissions}
|
|
disabled={!can("sys_role:permission:save")}
|
|
>
|
|
保存权限配置
|
|
</Button>
|
|
}
|
|
>
|
|
<Tabs defaultActiveKey="permissions" className="role-tabs">
|
|
<Tabs.TabPane
|
|
tab={<Space><KeyOutlined />功能权限</Space>}
|
|
key="permissions"
|
|
>
|
|
<div className="role-permission-tree-v2">
|
|
<Tree
|
|
checkable
|
|
selectable={false}
|
|
checkStrictly={false}
|
|
treeData={permissionTreeData}
|
|
checkedKeys={selectedPermIds}
|
|
onCheck={(keys, info) => {
|
|
const checked = Array.isArray(keys) ? keys : keys.checked;
|
|
const halfChecked = info.halfCheckedKeys || [];
|
|
setSelectedPermIds(checked.map(k => Number(k)));
|
|
setHalfCheckedIds(halfChecked.map(k => Number(k)));
|
|
}}
|
|
defaultExpandAll
|
|
/>
|
|
</div>
|
|
</Tabs.TabPane>
|
|
<Tabs.TabPane
|
|
tab={<Space><UserOutlined />关联用户 ({roleUsers.length})</Space>}
|
|
key="users"
|
|
>
|
|
<Table
|
|
rowKey="userId"
|
|
size="small"
|
|
loading={loadingUsers}
|
|
dataSource={roleUsers}
|
|
pagination={{ pageSize: 10 }}
|
|
columns={[
|
|
{
|
|
title: '用户',
|
|
render: (_, r) => (
|
|
<Space>
|
|
<UserOutlined />
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{r.displayName}</div>
|
|
<div style={{ fontSize: 12, color: '#8c8c8c' }}>@{r.username}</div>
|
|
</div>
|
|
</Space>
|
|
)
|
|
},
|
|
{ title: '手机号', dataIndex: 'phone' },
|
|
{ title: '邮箱', dataIndex: 'email' },
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
|
}
|
|
]}
|
|
/>
|
|
</Tabs.TabPane>
|
|
</Tabs>
|
|
</Card>
|
|
) : (
|
|
<Card className="full-height-card flex-center">
|
|
<Empty description="请从左侧选择一个角色以查看详情" />
|
|
</Card>
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* Basic Info Drawer */}
|
|
<Drawer
|
|
title={editing ? "修改角色基础信息" : "新增系统角色"}
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
width={400}
|
|
destroyOnClose
|
|
footer={
|
|
<div className="flex justify-end gap-2">
|
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
|
<Button type="primary" loading={saving} onClick={submitBasic}>提交</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}>
|
|
<Input placeholder="输入名称" />
|
|
</Form.Item>
|
|
<Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}>
|
|
<Input placeholder="输入唯一编码" disabled={!!editing} />
|
|
</Form.Item>
|
|
<Form.Item label="状态" name="status" initialValue={1}>
|
|
<Select options={[{label: '启用', value: 1}, {label: '禁用', value: 0}]} />
|
|
</Form.Item>
|
|
<Form.Item label="备注" name="remark">
|
|
<Input.TextArea rows={3} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
import { Select } from "antd";
|