imeeting/frontend/src/pages/Roles.tsx

661 lines
22 KiB
TypeScript
Raw Normal View History

import {
Button,
Card,
Drawer,
Form,
Input,
message,
Popconfirm,
Space,
Table,
Tag,
Typography,
Tree,
Row,
Col,
Tabs,
Empty,
Select,
Modal
} 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,
bindUsersToRole,
unbindUserFromRole,
listUsers
} from "../api";
import type { SysPermission, SysRole, SysUser } from "../types";
import { usePermission } from "../hooks/usePermission";
import {
EditOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SearchOutlined,
DeleteOutlined,
KeyOutlined,
UserOutlined,
SaveOutlined,
UserAddOutlined
} 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);
// 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), []);
// Right side states
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
// User selection states
const [allUsers, setAllUsers] = useState<SysUser[]>([]);
const [userModalOpen, setUserModalOpen] = useState(false);
const [selectedUserKeys, setSelectedUserKeys] = useState<number[]>([]);
const [userSearchText, setUserSearchText] = useState("");
// Search
const [searchText, setSearchText] = useState("");
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
// Drawer (Only for Add/Edit basic info)
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null);
const [tenants, setTenants] = useState<SysTenant[]>([]);
const [form] = Form.useForm();
const { can } = usePermission();
const loadTenants = async () => {
if (!isPlatformMode) return;
try {
const resp = await (await import("../api")).listTenants({ current: 1, size: 100 });
setTenants(resp.records || []);
} catch (e) {}
};
useEffect(() => {
loadTenants();
}, [isPlatformMode]);
const permissionTreeData = useMemo(
() => toTreeData(buildPermissionTree(permissions), t),
[permissions, t]
);
const loadAllUsers = async () => {
try {
const list = await listUsers();
setAllUsers(list || []);
} catch (e) {
console.error(e);
}
};
const openUserModal = () => {
loadAllUsers();
setSelectedUserKeys([]);
setUserModalOpen(true);
};
const handleAddUsers = async () => {
if (!selectedRole || selectedUserKeys.length === 0) return;
try {
await bindUsersToRole(selectedRole.roleId, selectedUserKeys);
message.success(t('common.success'));
setUserModalOpen(false);
selectRole(selectedRole);
} catch (e) {
message.error(t('common.error'));
}
};
const handleUnbindUser = async (userId: number) => {
if (!selectedRole) return;
try {
await unbindUserFromRole(selectedRole.roleId, userId);
message.success(t('common.success'));
selectRole(selectedRole);
} catch (e) {
message.error(t('common.error'));
}
};
const filteredModalUsers = useMemo(() => {
const existingIds = new Set(roleUsers.map(u => u.userId));
return allUsers.filter(u =>
!existingIds.has(u.userId) &&
(u.username.toLowerCase().includes(userSearchText.toLowerCase()) ||
u.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
);
}, [allUsers, roleUsers, userSearchText]);
const loadPermissions = async () => {
try {
const list = await listPermissions();
setPermissions(list || []);
} catch (e) {
setPermissions([]);
}
};
const loadRoles = async () => {
setLoading(true);
try {
const list = await listRoles();
let roles = list || [];
if (isPlatformMode && filterTenantId !== undefined) {
roles = roles.filter(r => r.tenantId === filterTenantId);
} else if (!isPlatformMode) {
roles = roles.filter(r => r.tenantId === activeTenantId);
}
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,
tenantId: isPlatformMode ? undefined : activeTenantId
});
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,
tenantId: values.tenantId
};
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 flex gap-2">
{isPlatformMode && (
<Select
placeholder={t('users.tenantFilter')}
style={{ width: 150 }}
allowClear
value={filterTenantId}
onChange={setFilterTenantId}
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
/>
)}
<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"
>
<div className="mb-4 flex justify-end">
<Button
type="primary"
icon={<UserAddOutlined aria-hidden="true" />}
onClick={openUserModal}
disabled={!can("sys_role:update")}
>
{t('common.create')}
</Button>
</div>
<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>
},
{
title: t('common.action'),
key: 'action',
width: 100,
render: (_, record) => (
<Popconfirm
title={t('common.delete') + "?"}
onConfirm={() => handleUnbindUser(record.userId)}
disabled={!can("sys_role:update")}
>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined aria-hidden="true" />}
disabled={!can("sys_role:update")}
/>
</Popconfirm>
)
}
]}
/>
</Tabs.TabPane>
</Tabs>
</Card>
) : (
<Card className="full-height-card flex items-center justify-center shadow-sm">
<Empty description={t('roles.selectRole')} />
</Card>
)}
</Col>
</Row>
{/* User Selection Modal */}
<Modal
title={t('roles.assignedUsers')}
open={userModalOpen}
onCancel={() => setUserModalOpen(false)}
onOk={handleAddUsers}
width={600}
destroyOnClose
>
<div className="mb-4">
<Input
placeholder={t('users.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />}
value={userSearchText}
onChange={e => setUserSearchText(e.target.value)}
allowClear
/>
</div>
<Table
rowKey="userId"
size="small"
dataSource={filteredModalUsers}
pagination={{ pageSize: 5 }}
rowSelection={{
selectedRowKeys: selectedUserKeys,
onChange: (keys) => setSelectedUserKeys(keys as number[])
}}
columns={[
{ title: t('users.displayName'), dataIndex: 'displayName' },
{ title: t('users.username'), dataIndex: 'username' },
{ title: t('users.org'), dataIndex: 'orgId', render: () => '-' } // Simplification
]}
/>
</Modal>
{/* 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('users.tenant')}
name="tenantId"
rules={[{ required: true }]}
hidden={!isPlatformMode}
>
<Select
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
disabled={!!editing}
/>
</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('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>
);
}