imeeting/frontend/src/pages/Roles.tsx

475 lines
16 KiB
TypeScript
Raw Normal View History

import {
Button,
Card,
Drawer,
Form,
Input,
message,
Popconfirm,
Space,
Table,
Tag,
Typography,
Tree,
Row,
Col,
Tabs,
Empty,
Select
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
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[], t: any): 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 }}>{t('permissions.permType') === '按钮' ? '按钮' : 'Button'}</Tag>}
</span>
),
children: node.children && node.children.length > 0 ? toTreeData(node.children, t) : undefined
}));
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
export default function Roles() {
const { t } = useTranslation();
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), t),
[permissions, t]
);
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(t('common.error'));
} finally {
setLoadingUsers(false);
}
};
useEffect(() => {
loadRoles();
}, []);
// Reload role detail if permissions list loaded later
useEffect(() => {
if (selectedRole && permissions.length > 0) {
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(t('common.success'));
if (selectedRole?.roleId === id) setSelectedRole(null);
loadRoles();
} catch (e) {
message.error(t('common.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(t('common.success'));
} else {
await createRole(payload);
message.success(t('common.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(t('common.success'));
} catch (e) {
message.error(t('common.error'));
} finally {
setSaving(false);
}
};
return (
<div className="roles-page-v2 p-6">
<Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
{/* Left: Role List */}
<Col span={8} style={{ height: '100%' }}>
<Card
title={t('roles.title')}
className="full-height-card shadow-sm"
extra={can("sys_role:create") && (
<Button
type="primary"
size="small"
icon={<PlusOutlined aria-hidden="true" />}
onClick={openCreate}
>
{t('common.create')}
</Button>
)}
>
<div className="mb-4">
<Input
placeholder={t('roles.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
aria-label={t('roles.searchPlaceholder')}
/>
</div>
<div className="role-list-container" style={{ height: 'calc(100% - 60px)', overflowY: 'auto' }}>
<Table
rowKey="roleId"
showHeader={false}
dataSource={filteredData}
loading={loading}
pagination={false}
locale={{ emptyText: <Empty description={t('roles.selectRole')} /> }}
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 flex justify-between items-center p-2">
<div className="role-item-main min-w-0">
<div className="role-item-name font-medium truncate">{record.roleName}</div>
<div className="role-item-code text-xs text-gray-400 truncate">{record.roleCode}</div>
</div>
<div className="role-item-actions flex gap-1">
{can("sys_role:update") && (
<Button
type="text"
size="small"
icon={<EditOutlined aria-hidden="true" />}
onClick={e => openEditBasic(e, record)}
/>
)}
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
<Popconfirm
title={`确定删除角色 "${record.roleName}" 吗?`}
onConfirm={e => handleRemove(e!, record.roleId)}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined aria-hidden="true" />}
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 shadow-sm"
title={
<Space>
<SafetyCertificateOutlined style={{ color: '#1890ff' }} aria-hidden="true" />
<span className="truncate max-w-[200px] inline-block align-bottom">{selectedRole.roleName}</span>
<Tag color="blue">{selectedRole.roleCode}</Tag>
</Space>
}
extra={
<Button
type="primary"
icon={<SaveOutlined aria-hidden="true" />}
loading={saving}
onClick={savePermissions}
disabled={!can("sys_role:permission:save")}
>
{t('roles.savePerms')}
</Button>
}
>
<Tabs defaultActiveKey="permissions" className="role-tabs">
<Tabs.TabPane
tab={<Space><KeyOutlined aria-hidden="true" />{t('roles.funcPerms')}</Space>}
key="permissions"
>
<div className="role-permission-tree-v2" style={{ maxHeight: 'calc(100vh - 400px)', overflowY: 'auto' }}>
<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 aria-hidden="true" />{t('roles.assignedUsers')} ({roleUsers.length})</Space>}
key="users"
>
<Table
rowKey="userId"
size="small"
loading={loadingUsers}
dataSource={roleUsers}
pagination={{ pageSize: 10, showTotal: (total) => t('common.total', { total }) }}
columns={[
{
title: t('users.userInfo'),
render: (_, r) => (
<Space>
<UserOutlined aria-hidden="true" />
<div className="min-w-0">
<div style={{ fontWeight: 500 }} className="truncate">{r.displayName}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate">@{r.username}</div>
</div>
</Space>
)
},
{ title: t('users.phone'), dataIndex: 'phone', className: 'tabular-nums' },
{ title: t('users.email'), dataIndex: 'email' },
{
title: t('common.status'),
dataIndex: 'status',
width: 80,
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
}
]}
/>
</Tabs.TabPane>
</Tabs>
</Card>
) : (
<Card className="full-height-card flex items-center justify-center shadow-sm">
<Empty description={t('roles.selectRole')} />
</Card>
)}
</Col>
</Row>
{/* Basic Info Drawer */}
<Drawer
title={editing ? t('roles.drawerTitleEdit') : t('roles.drawerTitleCreate')}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={400}
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={submitBasic}>{t('common.confirm')}</Button>
</div>
}
>
<Form form={form} layout="vertical">
<Form.Item label={t('roles.roleName')} name="roleName" rules={[{ required: true }]}>
<Input placeholder={t('roles.roleName')} />
</Form.Item>
<Form.Item label={t('roles.roleCode')} name="roleCode" rules={[{ required: true }]}>
<Input placeholder={t('roles.roleCode')} disabled={!!editing} />
</Form.Item>
<Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select options={[{label: '启用', value: 1}, {label: '禁用', value: 0}]} />
</Form.Item>
<Form.Item label={t('common.remark')} name="remark">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Drawer>
</div>
);
}