import { Avatar, Badge, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row } from "antd"; import type { DataNode } from "antd/es/tree"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApartmentOutlined, CheckCircleFilled, DeleteOutlined, EditOutlined, FilterOutlined, KeyOutlined, PlusOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined, TeamOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons"; import { bindUsersToRole, createRole, deleteRole, fetchUsersByRoleId, listPermissions, listRolePermissions, listTenants, listUsers, pageRoles, saveRolePermissions, unbindUserFromRole, updateRole } from "@/api"; import { useDict } from "@/hooks/useDict"; import { usePermission } from "@/hooks/usePermission"; import PageHeader from "@/components/shared/PageHeader"; import type { SysPermission, SysRole, SysTenant, SysUser } from "@/types"; import "./index.less"; const { Text, Title } = Typography; type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] }; const DEFAULT_STATUS = 1; const DEFAULT_ROLE_PAGE_SIZE = 10; 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(); 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 toTreeData(nodes: PermissionNode[], t: (key: string, options?: Record) => string, buttonShortLabel: string): DataNode[] { return nodes.map((node) => ({ key: node.permId, title: ( {node.name} {node.permType === "button" ? {buttonShortLabel} : null} ), children: node.children?.length ? toTreeData(node.children, t, buttonShortLabel) : undefined })); } const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`; export default function Roles() { const { t } = useTranslation(); const { can } = usePermission(); const { items: statusDict } = useDict("sys_common_status"); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [data, setData] = useState([]); const [permissions, setPermissions] = useState([]); const [selectedRole, setSelectedRole] = useState(null); const [selectedPermIds, setSelectedPermIds] = useState([]); const [halfCheckedIds, setHalfCheckedIds] = useState([]); const [roleUsers, setRoleUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(false); const [allUsers, setAllUsers] = useState([]); const [userModalOpen, setUserModalOpen] = useState(false); const [selectedUserKeys, setSelectedUserKeys] = useState([]); const [userSearchText, setUserSearchText] = useState(""); const [searchText, setSearchText] = useState(""); const [rolePage, setRolePage] = useState({ current: 1, size: DEFAULT_ROLE_PAGE_SIZE, total: 0 }); const handleSearch = () => { setSearchText((value) => value.trim()); setRolePage((prev) => ({ ...prev, current: 1 })); }; const handleResetSearch = () => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }; const [filterTenantId, setFilterTenantId] = useState(undefined); const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [tenants, setTenants] = useState([]); const [form] = Form.useForm(); const isPlatformMode = useMemo(() => { const profileStr = sessionStorage.getItem("userProfile"); if (!profileStr) return false; const profile = JSON.parse(profileStr); return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0"; }, []); const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []); const buttonShortLabel = useMemo(() => { const label = t("buttonShort"); return !label || label === "buttonShort" ? "BTN" : label; }, [t]); const permissionTreeData = useMemo(() => toTreeData(buildPermissionTree(permissions), t, buttonShortLabel), [buttonShortLabel, permissions, t]); 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 || []); } catch { setPermissions([]); } }; const selectRole = async (role: SysRole) => { setSelectedRole(role); try { const ids = await listRolePermissions(role.roleId); const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)); const leafIds = normalized.filter((id) => !permissions.some((permission) => permission.parentId === id)); setSelectedPermIds(leafIds); setHalfCheckedIds([]); setLoadingUsers(true); const users = await fetchUsersByRoleId(role.roleId); setRoleUsers(users || []); } finally { setLoadingUsers(false); } }; const loadRoles = async (page = rolePage.current, size = rolePage.size) => { setLoading(true); try { 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([]); } else if (!selectedRole) { await selectRole(roles[0]); } else { const updated = roles.find((role) => role.roleId === selectedRole.roleId); if (updated) { setSelectedRole(updated); } else { await selectRole(roles[0]); } } await loadPermissions(); } finally { setLoading(false); } }; useEffect(() => { 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 = () => { loadAllUsers(); setSelectedUserKeys([]); setUserModalOpen(true); }; const handleAddUsers = async () => { if (!selectedRole || selectedUserKeys.length === 0) return; await bindUsersToRole(selectedRole.roleId, selectedUserKeys); message.success(t("common.success")); setUserModalOpen(false); selectRole(selectedRole); }; const handleUnbindUser = async (userId: number) => { if (!selectedRole) return; if (selectedRole.roleCode === "TENANT_ADMIN" && roleUsers.length <= 1) { message.warning(t("rolesExt.tenantAdminWarning")); return; } await unbindUserFromRole(selectedRole.roleId, userId); message.success(t("common.success")); selectRole(selectedRole); }; 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]); 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(t("common.success")); if (selectedRole?.roleId === id) setSelectedRole(null); loadRoles(rolePage.current, rolePage.size); }; const submitBasic = async () => { const values = await form.validateFields(); setSaving(true); try { const payload: Partial = { 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); } else { await createRole(payload); } message.success(t("common.success")); setDrawerOpen(false); 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(t("common.success")); } finally { setSaving(false); } }; return (
{can("sys:role:create") && }
{t("rolesExt.roleList")} {/**/} } bordered={false} className="app-page__panel-card roles-side-card">
{isPlatformMode && ( } value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
}} renderItem={(item) => (
selectRole(item)}>
{item.roleName} {isPlatformMode && {item.tenantId === 0 ? t("rolesExt.systemTenant") : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `${t("rolesExt.tenantLabel")}:${item.tenantId}`}} {item.status === 0 && {t("rolesExt.disabled")}}
{item.roleCode}
{selectedRole?.roleId === item.roleId ? ( ) : null}
)} />
{selectedRole ? (
{selectedRole.roleName}
{selectedRole.roleCode}
} extra={} > {t("roles.funcPerms")}} key="permissions">
{ 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 />
{t("rolesExt.membersTab")} ({roleUsers.length})} key="users">
{t("rolesExt.assignedUsers")}
( } style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
{user.displayName}
@{user.username}
) }, { title: t("rolesExt.phone"), dataIndex: "phone", className: "tabular-nums" }, { title: t("common.status"), dataIndex: "status", width: 80, render: (status: number) => {status === 1 ? t("logsExt.success") : t("rolesExt.disabled")} }, { title: t("common.action"), key: "action", width: 80, render: (_: any, user: SysUser) => ( handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
setSelectedUserKeys(keys as number[]) }} columns={[{ title: t("rolesExt.displayName"), dataIndex: "displayName" }, { title: t("users.username"), dataIndex: "username" }, { title: t("rolesExt.phone"), dataIndex: "phone" }]} /> setDrawerOpen(false)} width={420} destroyOnClose footer={
}>