/* eslint-disable max-lines-per-function, complexity -- 编辑器页负责角色加载、草稿编辑、保存编排与离开拦截,集中便于追踪数据流 */ import { Button } from '@douyinfe/semi-ui-19'; import { Permission, type PermissionNode, type PermissionTree, type RoleDetail, type RoleListItem, type SodConflict, } from '@lingxi/shared'; import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui'; import { IconAlert, IconBack, IconCopy, IconPause, IconPlay, IconShield } from '@lingxi/ui/icons'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useBlocker, useNavigate, useParams } from 'react-router'; import { ApiError } from '@/api/client'; import { Can } from '@/components/Can'; import { usePermissionTreeQuery, useRoleDetailQuery, useRoleListQuery, useUpdateRoleMutation, } from '@/hooks/queries/use-roles'; import { usePermission } from '@/hooks/use-permission'; import { roleDetailPath, roleNewPath, ROUTES } from '@/routes'; import { DisableRoleDialog, LeaveRoleDialog, SaveRoleDialog, SpecialPermissionDialog, } from './role-dialogs'; import { buildPermissionMetaMap, formatRoleDateTime, scopeLabelOf } from './role-display'; import { conflictsFromErrorDetails, type RoleDraftErrors, saveResultMessage, validateUpdateDraft, } from './role-utils'; import { type RoleEditorTabKey, RoleEditorTabs } from './RoleEditorTabs'; import { RoleListPanel } from './RoleListPanel'; import { RoleStatusTag } from './RoleStatusTag'; import { useRoleEditor } from './use-role-editor'; /** 左侧角色切换列表:与列表页同一查询口径,全部状态一次拉取 */ const PEER_ROLE_QUERY = { limit: 100, status: 'all', sort: 'created_at', order: 'desc', } as const; interface EditorContentProps { detail: RoleDetail; tree: PermissionTree | undefined; treeLoading: boolean; treeError: Error | null; onRetryTree: () => void; peers: readonly RoleListItem[]; peersLoading: boolean; peersError: Error | null; onRetryPeers: () => void; onSelectPeer: (roleId: string) => void; canManage: boolean; } function mergeConflicts( local: readonly SodConflict[], server: readonly SodConflict[], ): SodConflict[] { const seen = new Set(); const merged: SodConflict[] = []; for (const conflict of [...local, ...server]) { if (seen.has(conflict.pairId)) { continue; } seen.add(conflict.pairId); merged.push(conflict); } return merged; } function conflictMessages( conflicts: readonly SodConflict[], meta: ReadonlyMap | null, ): string { return conflicts .map((conflict) => { const left = meta?.get(conflict.permissions[0])?.name ?? conflict.permissions[0]; const right = meta?.get(conflict.permissions[1])?.name ?? conflict.permissions[1]; return `命中职责互斥:${conflict.label}。请移除「${left}」或「${right}」后重试。`; }) .join(' '); } function EditorAlert({ readonly, locked, conflicts, saveError, meta, }: { readonly: boolean; locked: boolean; conflicts: readonly SodConflict[]; saveError: string | null; meta: ReadonlyMap | null; }): ReactNode { if (readonly) { return (
你没有角色管理权限(role:manage),当前为只读视图。如需调整请联系平台管理员。
); } if (locked) { return (
超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。
); } if (conflicts.length > 0) { return (
{conflictMessages(conflicts, meta)}
); } if (saveError !== null) { return (
{saveError}
); } return null; } function EditorFoot({ readonly, locked, conflicts, editor, saving, affectedUsers, onCancel, onSave, }: { readonly: boolean; locked: boolean; conflicts: readonly SodConflict[]; editor: ReturnType; saving: boolean; affectedUsers: number; onCancel: () => void; onSave: () => void; }): ReactNode { if (readonly) { return (
只读视图:可以查看权限配置,但没有修改权限
); } const dirty = editor.isDirty; return (
{conflicts.length > 0 ? ( 存在职责互斥,保存不可用 ) : !dirty ? ( 未做修改 ) : ( <> 未保存 {editor.diff.totalAdded > 0 ? ( 本次新增 {editor.diff.totalAdded} ) : null} {editor.diff.totalRemoved > 0 ? ( 移除 {editor.diff.totalRemoved} ) : null} {editor.diff.nameChanged || editor.diff.descriptionChanged ? ( 基本信息已修改 ) : null} {editor.diff.scopeChanged ? 数据范围已修改 : null} 影响 {affectedUsers} 名用户 )}
); } function EditorContent({ detail, tree, treeLoading, treeError, onRetryTree, peers, peersLoading, peersError, onRetryPeers, onSelectPeer, canManage, }: EditorContentProps): ReactNode { const navigate = useNavigate(); const editor = useRoleEditor(detail); const updateRole = useUpdateRoleMutation(); const locked = detail.isSystem; const readonly = !canManage; const [activeTab, setActiveTab] = useState('basic'); const [errors, setErrors] = useState({}); const draftFingerprint = useMemo(() => JSON.stringify(editor.draft), [editor.draft]); const [serverState, setServerState] = useState<{ fingerprint: string; conflicts: readonly SodConflict[]; saveError: string | null; } | null>(null); const serverConflicts = useMemo( () => serverState !== null && serverState.fingerprint === draftFingerprint ? serverState.conflicts : [], [serverState, draftFingerprint], ); const saveError = serverState !== null && serverState.fingerprint === draftFingerprint ? serverState.saveError : null; const [saveOpen, setSaveOpen] = useState(false); const [specialPrompt, setSpecialPrompt] = useState(false); const [specialDialog, setSpecialDialog] = useState<{ permission: PermissionNode; granted: boolean; } | null>(null); const [disableOpen, setDisableOpen] = useState(false); const blocker = useBlocker(editor.isDirty); const conflicts = useMemo( () => (detail.isSystem ? [] : mergeConflicts(editor.conflicts, serverConflicts)), [detail.isSystem, editor.conflicts, serverConflicts], ); const meta = useMemo(() => (tree === undefined ? null : buildPermissionMetaMap(tree)), [tree]); useEffect(() => { if (!editor.isDirty) { return undefined; } const handler = (event: BeforeUnloadEvent): void => { event.preventDefault(); // eslint-disable-next-line @typescript-eslint/no-deprecated -- 浏览器离开提示仍依赖 returnValue event.returnValue = ''; }; window.addEventListener('beforeunload', handler); return () => { window.removeEventListener('beforeunload', handler); }; }, [editor.isDirty]); const specialChangedCodes = useMemo( () => [...editor.diff.addedSpecial, ...editor.diff.removedSpecial], [editor.diff.addedSpecial, editor.diff.removedSpecial], ); const attemptSave = (): void => { if (readonly) { return; } const validation = validateUpdateDraft(detail, editor.draft); setErrors(validation.errors); if (validation.input === null) { setActiveTab('basic'); notifyWarning(validation.errors.name ?? validation.errors.description ?? '请检查角色信息'); return; } if (!editor.isDirty) { notifyWarning('未做修改'); return; } if (editor.diff.hasSpecialChanges) { setSpecialPrompt(true); return; } setSaveOpen(true); }; const doSave = (): void => { const validation = validateUpdateDraft(detail, editor.draft); if (validation.input === null) { setErrors(validation.errors); setSaveOpen(false); setActiveTab('basic'); notifyWarning(validation.errors.name ?? '请检查角色信息'); return; } updateRole.mutate( { roleId: detail.id, input: validation.input }, { onSuccess: (result) => { setSaveOpen(false); setSpecialPrompt(false); setErrors({}); editor.markSaved(); if (detail.isSystem) { notifySuccess('角色名称已更新'); } else { notifySuccess(saveResultMessage(result)); } if (result.warnings.length > 0) { notifyWarning(result.warnings.join(';')); } }, onError: (error) => { setSaveOpen(false); setSpecialPrompt(false); if (error instanceof ApiError && error.code === 40304) { setServerState({ fingerprint: draftFingerprint, conflicts: conflictsFromErrorDetails(error.details), saveError: '保存时服务端检测到职责互斥,请调整冲突权限点后重试。', }); setActiveTab('permissions'); return; } if (error instanceof ApiError) { setServerState({ fingerprint: draftFingerprint, conflicts: [], saveError: error.message, }); } }, }, ); }; const toggleSpecial = (code: string, granted: boolean): void => { const permission = tree?.specialPermissions.find((item) => item.code === code); if (permission === undefined) { return; } setSpecialDialog({ permission, granted }); }; const headerTitle = editor.draft.name.trim() === '' ? detail.name : editor.draft.name; const impactUsers = detail.userCount; return ( <>

{headerTitle}

{detail.isSystem ? ( 内置 ) : null} {readonly ? 只读 : null}

调整角色权限、数据范围与特殊权限,保存后对绑定用户生效

{detail.isSystem ? null : detail.status === 'active' ? ( ) : ( )}
{ // 编辑器内左侧列表仅用于切换角色,不提供行菜单 }} onCreate={() => { void navigate(ROUTES.system.roleNew); }} onResetFilters={() => { // 编辑器内左侧列表不展示筛选 }} onRetry={onRetryPeers} />
{headerTitle} {detail.isSystem ? ( 内置 ) : null}
{detail.code} · {detail.userCount} 名用户 · 最近更新 {formatRoleDateTime(detail.updatedAt)} · {scopeLabelOf(detail.dataScope.type)}
{ void navigate(ROUTES.system.roles); }} onSave={attemptSave} />
{ setDisableOpen(false); }} onConfirm={(reason) => { updateRole.mutate( { roleId: detail.id, input: { status: 'disabled', ...(reason === '' ? {} : { reason }) }, }, { onSuccess: () => { setDisableOpen(false); notifySuccess(`「${detail.name}」已停用,${impactUsers} 名用户下一次请求生效`); }, }, ); }} /> { setSpecialDialog(null); }} onConfirm={() => { if (specialDialog === null) { return; } editor.setSpecialPermission(specialDialog.permission.code, specialDialog.granted); setSpecialDialog(null); notifySuccess( specialDialog.granted ? `已设置「${specialDialog.permission.name}」,保存后生效` : `已取消「${specialDialog.permission.name}」,保存后生效`, ); }} /> {editor.diff.hasSpecialChanges ? ( item.code === specialChangedCodes[0]) ?? null } granted={editor.diff.addedSpecial.length > 0} holderCount={impactUsers} onCancel={() => { setSpecialPrompt(false); }} onConfirm={() => { setSpecialPrompt(false); setSaveOpen(true); }} /> ) : null} { setSaveOpen(false); }} onConfirm={doSave} /> { if (blocker.state === 'blocked') { blocker.reset(); } }} onConfirm={() => { if (blocker.state === 'blocked') { blocker.proceed(); } }} /> ); } function EditorLoading(): ReactNode { return (
); } /** 角色编辑器页(/system/roles/:id):左列表常驻高亮 + 右 Tab 编辑器 + 底部保存条 */ export function RoleEditorPage(): ReactNode { const params = useParams<{ id: string }>(); const roleId = params.id ?? null; const navigate = useNavigate(); const canManage = usePermission(Permission.ROLE_MANAGE); const tree = usePermissionTreeQuery(); const peers = useRoleListQuery(PEER_ROLE_QUERY); const detail = useRoleDetailQuery(roleId); const selectPeer = (nextRoleId: string): void => { if (nextRoleId === roleId) { return; } void navigate(roleDetailPath(nextRoleId)); }; const peerRoles: readonly RoleListItem[] = useMemo(() => peers.data?.items ?? [], [peers.data]); if (roleId === null) { return ; } if (detail.isPending) { return (
); } if (!detail.isSuccess) { return (
); } return (
{ void tree.refetch(); }} peers={peerRoles} peersLoading={peers.isPending} peersError={peers.error} onRetryPeers={() => { void peers.refetch(); }} onSelectPeer={selectPeer} canManage={canManage} />
); }