imeeting/frontend/src/pages/Roles.tsx

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";