imeeting/frontend/src/pages/access/roles/index.tsx

661 lines
28 KiB
TypeScript

import { Avatar, Button, Card, Col, Drawer, Empty, Form, Input, List, Radio, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row, App } from 'antd';
import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react";
import {
ApartmentOutlined,
CheckCircleFilled,
DeleteOutlined,
EditOutlined,
FilterOutlined,
KeyOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SaveOutlined,
SearchOutlined,
TeamOutlined,
UserAddOutlined,
UserOutlined
} from "@ant-design/icons";
import {
bindUsersToRole,
createRole,
deleteRole,
fetchUsersByRoleId,
getRoleDataScope,
listOrgs,
listPermissions,
listRolePermissions,
listTenants,
listUsers,
pageRoles,
saveRoleDataScope,
saveRolePermissions,
unbindUserFromRole,
updateRole
} from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import AppPagination from "@/components/shared/AppPagination";
import PageHeader from "@/components/shared/PageHeader";
import { getStandardPagination } from "@/utils/pagination";
import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less";
const { Text, Title } = Typography;
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
type OrgTreeNode = SysOrg & { key: number; children?: OrgTreeNode[] };
type RoleTabKey = "permissions" | "dataScope" | "users";
const DEFAULT_STATUS = 1;
const DEFAULT_ROLE_PAGE_SIZE = 10;
const BUTTON_SHORT_LABEL = "按钮";
const DATA_SCOPE_OPTIONS = [
{ label: "全部", value: "ALL" },
{ label: "个人", value: "SELF" },
{ label: "本部门", value: "DEPT" },
{ label: "本部门及下级部门", value: "DEPT_AND_CHILD" },
{ label: "自定义部门", value: "CUSTOM" }
] as const;
function normalizeNumber(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
if (value && typeof value === "object" && "value" in value) {
return normalizeNumber((value as { value?: unknown }).value);
}
return undefined;
}
function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
const active = (list || []).filter((permission) => permission.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((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0));
nodes.forEach((node) => node.children && sortNodes(node.children));
};
sortNodes(roots);
return roots;
}
function toPermissionTreeData(nodes: PermissionNode[], buttonShortLabel: string): DataNode[] {
return nodes.map((node) => ({
key: node.permId,
title: (
<Space>
<span>{node.name}</span>
{node.permType === "button" ? <Tag color="cyan" style={{ fontSize: 10 }}>{buttonShortLabel}</Tag> : null}
</Space>
),
children: node.children?.length ? toPermissionTreeData(node.children, buttonShortLabel) : undefined
}));
}
function buildOrgTree(list: SysOrg[]): OrgTreeNode[] {
const map = new Map<number, OrgTreeNode>();
const roots: OrgTreeNode[] = [];
list.forEach((item) => {
map.set(item.id, { ...item, key: item.id, children: [] });
});
map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children!.push(node);
} else {
roots.push(node);
}
});
const sortNodes = (nodes: OrgTreeNode[]) => {
nodes.sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0));
nodes.forEach((node) => node.children && sortNodes(node.children));
};
sortNodes(roots);
return roots;
}
function toOrgTreeData(nodes: OrgTreeNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
title: node.orgName,
children: node.children?.length ? toOrgTreeData(node.children) : undefined
}));
}
function getDataScopeDescription(scopeType: string) {
switch (scopeType) {
case "ALL":
return "当前角色可访问当前租户全部数据。";
case "SELF":
return "当前角色仅可访问本人数据。";
case "DEPT":
return "当前角色可访问本人所在部门的数据。";
case "DEPT_AND_CHILD":
return "当前角色可访问本人所在部门及所有下级部门的数据。";
case "CUSTOM":
return "当前角色可访问选中部门的数据。";
default:
return "";
}
}
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
export default function Roles() {
const { message } = App.useApp();
const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status");
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);
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [allUsers, setAllUsers] = useState<SysUser[]>([]);
const [userModalOpen, setUserModalOpen] = useState(false);
const [selectedUserKeys, setSelectedUserKeys] = useState<number[]>([]);
const [userSearchText, setUserSearchText] = useState("");
const [searchText, setSearchText] = useState("");
const [rolePage, setRolePage] = useState({ current: 1, size: DEFAULT_ROLE_PAGE_SIZE, total: 0 });
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null);
const [tenants, setTenants] = useState<SysTenant[]>([]);
const [activeTab, setActiveTab] = useState<RoleTabKey>("permissions");
const [dataScopeType, setDataScopeType] = useState("SELF");
const [scopeOrgIds, setScopeOrgIds] = useState<number[]>([]);
const [scopeOrgTree, setScopeOrgTree] = useState<DataNode[]>([]);
const [form] = Form.useForm();
const isPlatformMode = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
if (!profileStr) return false;
const profile = JSON.parse(profileStr);
return !!profile.isPlatformAdmin;
}, []);
const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []);
const permissionTreeData = useMemo(() => toPermissionTreeData(buildPermissionTree(permissions), BUTTON_SHORT_LABEL), [permissions]);
const filteredModalUsers = useMemo(() => {
const existingIds = new Set(roleUsers.map((user) => user.userId));
return allUsers.filter(
(user) =>
!existingIds.has(user.userId) &&
(user.username.toLowerCase().includes(userSearchText.toLowerCase()) || user.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
);
}, [allUsers, roleUsers, userSearchText]);
useEffect(() => {
if (!isPlatformMode) return;
listTenants({ current: 1, size: 100 }).then((response) => setTenants(response.records || [])).catch(() => {});
}, [isPlatformMode]);
const loadPermissions = async () => {
try {
const list = await listPermissions();
setPermissions(list || []);
return list || [];
} catch {
setPermissions([]);
return [] as SysPermission[];
}
};
const selectRole = async (role: SysRole, permissionList: SysPermission[] = permissions) => {
setSelectedRole(role);
setLoadingUsers(true);
try {
const [ids, users, dataScope, orgs] = await Promise.all([
listRolePermissions(role.roleId),
fetchUsersByRoleId(role.roleId),
getRoleDataScope(role.roleId),
listOrgs(role.tenantId)
]);
const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
const leafIds = normalized.filter((id) => !permissionList.some((permission) => permission.parentId === id));
setSelectedPermIds(leafIds);
setHalfCheckedIds([]);
setRoleUsers(users || []);
setDataScopeType(dataScope?.scopeType || role.dataScopeType || "SELF");
setScopeOrgIds((dataScope?.orgIds || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)));
setScopeOrgTree(toOrgTreeData(buildOrgTree(orgs || [])));
} finally {
setLoadingUsers(false);
}
};
const loadRoles = async (page = rolePage.current, size = rolePage.size) => {
setLoading(true);
try {
const permissionList = await loadPermissions();
const response = await pageRoles({
current: page,
size,
tenantId: isPlatformMode ? filterTenantId : activeTenantId,
keyword: searchText || undefined
});
const roles = response?.records || [];
setRolePage({ current: page, size, total: response?.total || 0 });
setData(roles);
if (roles.length === 0) {
setSelectedRole(null);
setRoleUsers([]);
setSelectedPermIds([]);
setHalfCheckedIds([]);
setDataScopeType("SELF");
setScopeOrgIds([]);
setScopeOrgTree([]);
} else if (!selectedRole) {
await selectRole(roles[0], permissionList);
} else {
const updated = roles.find((role) => role.roleId === selectedRole.roleId);
if (updated) {
await selectRole(updated, permissionList);
} else {
await selectRole(roles[0], permissionList);
}
}
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRoles(rolePage.current, rolePage.size);
}, [filterTenantId, rolePage.current, rolePage.size, searchText]);
const loadAllUsers = async () => {
try {
const list = await listUsers();
setAllUsers(list || []);
} catch {
setAllUsers([]);
}
};
const openUserModal = () => {
void loadAllUsers();
setSelectedUserKeys([]);
setUserModalOpen(true);
};
const handleAddUsers = async () => {
if (!selectedRole || selectedUserKeys.length === 0) return;
await bindUsersToRole(selectedRole.roleId, selectedUserKeys);
message.success("操作成功");
setUserModalOpen(false);
await selectRole(selectedRole);
};
const handleUnbindUser = async (userId: number) => {
if (!selectedRole) return;
if (selectedRole.roleCode === "TENANT_ADMIN" && roleUsers.length <= 1) {
message.warning("租户管理员角色至少需要保留一个绑定用户");
return;
}
await unbindUserFromRole(selectedRole.roleId, userId);
message.success("操作成功");
await selectRole(selectedRole);
};
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ status: 1, tenantId: isPlatformMode ? undefined : activeTenantId });
setDrawerOpen(true);
};
const openEditBasic = (event: React.MouseEvent, record: SysRole) => {
event.stopPropagation();
setEditing(record);
form.setFieldsValue(record);
setDrawerOpen(true);
};
const handleRemove = async (event: React.MouseEvent, id: number) => {
event.stopPropagation();
await deleteRole(id);
message.success("操作成功");
if (selectedRole?.roleId === id) setSelectedRole(null);
await loadRoles(rolePage.current, rolePage.size);
};
const submitBasic = async () => {
const values = await form.validateFields();
setSaving(true);
try {
const payload: Partial<SysRole> = {
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
roleName: values.roleName,
remark: values.remark,
status: values.status ?? DEFAULT_STATUS,
tenantId: values.tenantId,
dataScopeType: editing?.dataScopeType || "SELF"
};
if (editing) {
await updateRole(editing.roleId, payload);
} else {
await createRole(payload);
}
message.success("操作成功");
setDrawerOpen(false);
await loadRoles(rolePage.current, rolePage.size);
} finally {
setSaving(false);
}
};
const handleRolePageChange = (page: number, pageSize: number) => {
setRolePage((prev) => ({ ...prev, current: page, size: pageSize }));
};
const savePermissions = async () => {
if (!selectedRole) return;
setSaving(true);
try {
await saveRolePermissions(selectedRole.roleId, Array.from(new Set([...selectedPermIds, ...halfCheckedIds])));
message.success("操作成功");
} finally {
setSaving(false);
}
};
const saveDataScope = async () => {
if (!selectedRole) return;
if (dataScopeType === "CUSTOM" && scopeOrgIds.length === 0) {
message.warning("请选择至少一个部门");
return;
}
setSaving(true);
try {
const payload: RoleDataScope = {
roleId: selectedRole.roleId,
scopeType: dataScopeType,
orgIds: dataScopeType === "CUSTOM" ? scopeOrgIds : []
};
await saveRoleDataScope(selectedRole.roleId, payload);
message.success("操作成功");
setSelectedRole((prev) => (prev ? { ...prev, dataScopeType } : prev));
setData((prev) => prev.map((item) => item.roleId === selectedRole.roleId ? { ...item, dataScopeType } : item));
} finally {
setSaving(false);
}
};
const handlePrimarySave = () => {
if (activeTab === "permissions") {
void savePermissions();
return;
}
if (activeTab === "dataScope") {
void saveDataScope();
}
};
const saveDisabled = !selectedRole || activeTab === "users" || (activeTab === "permissions" && !can("sys:role:permission:save")) || (activeTab === "dataScope" && !can("sys:role:update"));
const saveLabel = activeTab === "dataScope" ? "保存数据权限" : "保存";
return (
<div className="app-page roles-page-v2">
<PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
<div className="app-page__page-actions">
{can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
</div>
<div className="roles-layout">
<Row gutter={24} className="roles-layout__row">
<Col span={7} className="roles-layout__side">
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} variant="borderless" className="app-page__panel-card roles-side-card">
<div className="role-search-panel">
{isPlatformMode && (
<Select
placeholder="按租户筛选"
style={{ width: "100%" }}
allowClear
suffixIcon={<FilterOutlined />}
value={filterTenantId}
onChange={(value) => { setFilterTenantId(normalizeNumber(value)); setRolePage((prev) => ({ ...prev, current: 1 })); }}
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
/>
)}
<div className="role-search-bar">
<Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
<Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
</div>
</div>
<div className="role-list-container-v3">
<List
loading={loading}
dataSource={data}
pagination={false}
locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
renderItem={(item) => (
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
<div className="role-item-symbol" aria-hidden="true">
<SafetyCertificateOutlined />
</div>
<div className="role-item-main">
<div className="role-item-name-row">
<Text strong className="role-name">{item.roleName}</Text>
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
</div>
<Text type="secondary" className="role-code">{item.roleCode}</Text>
</div>
{selectedRole?.roleId === item.roleId ? (
<div className="role-item-selected-mark" aria-hidden="true">
<CheckCircleFilled />
</div>
) : null}
<div className="role-item-actions">
<Space size={4}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
</Tooltip>
{item.roleCode !== "ADMIN" && (
<Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
</Popconfirm>
)}
</Space>
</div>
</div>
)}
/>
</div>
<div className="role-list-pagination">
<AppPagination
current={rolePage.current}
pageSize={rolePage.size}
total={rolePage.total}
onChange={handleRolePageChange}
/>
</div>
</Card>
</Col>
<Col span={17} className="roles-layout__detail">
{selectedRole ? (
<Card
className="app-page__panel-card roles-detail-card"
variant="borderless"
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as RoleTabKey)}
className="role-detail-tabs"
items={[
{
key: "permissions",
label: <Space><KeyOutlined />{"功能权限"}</Space>,
children: (
<div className="role-detail-pane">
<div className="permission-tree-wrapper">
<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((key) => Number(key)));
setHalfCheckedIds(halfChecked.map((key) => Number(key)));
}}
defaultExpandAll
/>
</div>
</div>
)
},
{
key: "dataScope",
label: <Space><ApartmentOutlined />{"数据权限"}</Space>,
children: (
<div className="role-detail-pane">
<div style={{ marginBottom: 16 }}>
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid">
{DATA_SCOPE_OPTIONS.map((item) => (
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
))}
</Radio.Group>
</div>
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
{dataScopeType === "CUSTOM" ? (
<div className="permission-tree-wrapper">
<Tree
checkable
selectable={false}
treeData={scopeOrgTree}
checkedKeys={scopeOrgIds}
onCheck={(keys) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
setScopeOrgIds(checked.map((key) => Number(key)));
}}
defaultExpandAll
/>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
)}
</div>
)
},
{
key: "users",
label: <Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>,
children: (
<div className="role-detail-pane">
<div className="role-members-toolbar">
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
</div>
<Table
rowKey="userId"
size="small"
loading={loadingUsers}
dataSource={roleUsers}
pagination={getStandardPagination(roleUsers.length, 1, 10)}
columns={[
{
title: "用户信息",
render: (_: unknown, user: SysUser) => (
<Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
<div>
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
</div>
</Space>
)
},
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
{
title: "操作",
key: "action",
width: 80,
render: (_: unknown, user: SysUser) => (
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
</Popconfirm>
)
}
]}
/>
</div>
)
}
]}
/>
</Card>
) : (
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
)}
</Col>
</Row>
</div>
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnHidden>
<div style={{ marginBottom: 16 }}>
<Input placeholder="搜索用户名或显示名称" prefix={<SearchOutlined />} value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear />
</div>
<Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={getStandardPagination(filteredModalUsers.length, 1, 6)} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} />
</Modal>
<Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
<Form form={form} layout="vertical">
<Form.Item label="租户" name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} />
</Form.Item>
<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={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} />
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={4} placeholder="请输入角色说明或适用范围" />
</Form.Item>
</Form>
</Drawer>
</div>
);
}