Files
lingxi/apps/web/src/pages/system/roles/RoleEditorPage.tsx
T
Stev_Wang 61894df401 feat(identity): 完成 A2 角色权限与数据范围全栈落地
背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
2026-11-03 12:26:51 +08:00

704 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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<string>();
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<string, { name: string }> | 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<string, { name: string }> | null;
}): ReactNode {
if (readonly) {
return (
<div className="lx-role-editor__alert is-info">
<IconAlert size={15} />
<span>你没有角色管理权限(role:manage),当前为只读视图。如需调整请联系平台管理员。</span>
</div>
);
}
if (locked) {
return (
<div className="lx-role-editor__alert is-info">
<IconAlert size={15} />
<span>超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。</span>
</div>
);
}
if (conflicts.length > 0) {
return (
<div className="lx-role-editor__alert is-danger">
<IconAlert size={15} />
<span>{conflictMessages(conflicts, meta)}</span>
</div>
);
}
if (saveError !== null) {
return (
<div className="lx-role-editor__alert is-danger">
<IconAlert size={15} />
<span>{saveError}</span>
</div>
);
}
return null;
}
function EditorFoot({
readonly,
locked,
conflicts,
editor,
saving,
affectedUsers,
onCancel,
onSave,
}: {
readonly: boolean;
locked: boolean;
conflicts: readonly SodConflict[];
editor: ReturnType<typeof useRoleEditor>;
saving: boolean;
affectedUsers: number;
onCancel: () => void;
onSave: () => void;
}): ReactNode {
if (readonly) {
return (
<footer className="lx-role-editor__foot">
<span className="lx-role-editor__impact is-clean">
只读视图:可以查看权限配置,但没有修改权限
</span>
</footer>
);
}
const dirty = editor.isDirty;
return (
<footer className="lx-role-editor__foot">
<div className="lx-role-editor__impact">
{conflicts.length > 0 ? (
<span className="is-blocked">
<IconAlert size={14} />
存在职责互斥,保存不可用
</span>
) : !dirty ? (
<span className="is-clean">未做修改</span>
) : (
<>
<span className="is-dirty">未保存</span>
{editor.diff.totalAdded > 0 ? (
<span>
本次新增 <b>{editor.diff.totalAdded}</b>
</span>
) : null}
{editor.diff.totalRemoved > 0 ? (
<span>
移除 <b>{editor.diff.totalRemoved}</b>
</span>
) : null}
{editor.diff.nameChanged || editor.diff.descriptionChanged ? (
<span>基本信息已修改</span>
) : null}
{editor.diff.scopeChanged ? <span>数据范围已修改</span> : null}
<span>
影响 <b>{affectedUsers}</b> 名用户
</span>
</>
)}
</div>
<div className="lx-role-editor__foot-actions">
<Button onClick={onCancel} disabled={saving}>
取消
</Button>
<Button
theme="solid"
type="primary"
loading={saving}
disabled={conflicts.length > 0 || !dirty}
onClick={onSave}
>
{locked ? '保存名称' : '保存'}
</Button>
</div>
</footer>
);
}
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<RoleEditorTabKey>('basic');
const [errors, setErrors] = useState<RoleDraftErrors>({});
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<readonly SodConflict[]>(
() =>
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 (
<>
<header className="lx-roles__header">
<button
type="button"
className="lx-roles__back"
aria-label="返回角色列表"
onClick={() => {
void navigate(ROUTES.system.roles);
}}
>
<IconBack size={18} />
</button>
<div className="lx-roles__head-txt">
<div className="lx-roles__title-line">
<h1>{headerTitle}</h1>
<RoleStatusTag status={detail.status} />
{detail.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
{readonly ? <span className="lx-role-tag">只读</span> : null}
</div>
<p>调整角色权限、数据范围与特殊权限,保存后对绑定用户生效</p>
</div>
<div className="lx-roles__header-actions">
<Can permission={Permission.ROLE_MANAGE}>
<Button
icon={<IconCopy size={14} />}
onClick={() => {
void navigate(roleNewPath(detail.id));
}}
>
复制
</Button>
{detail.isSystem ? null : detail.status === 'active' ? (
<Button
type="danger"
icon={<IconPause size={14} />}
onClick={() => {
setDisableOpen(true);
}}
>
停用
</Button>
) : (
<Button
icon={<IconPlay size={14} />}
onClick={() => {
updateRole.mutate(
{ roleId: detail.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${detail.name}」已启用`);
},
},
);
}}
>
启用
</Button>
)}
</Can>
</div>
</header>
<div className="lx-roles__split">
<RoleListPanel
roles={peers}
totalCount={peers.length}
loading={peersLoading}
error={peersError}
selectedRoleId={detail.id}
note={`当前:${detail.name}`}
footerNote="切换后继续编辑"
canManage={canManage}
showRowMenu={false}
isFiltered={false}
onSelect={onSelectPeer}
onRowAction={() => {
// 编辑器内左侧列表仅用于切换角色,不提供行菜单
}}
onCreate={() => {
void navigate(ROUTES.system.roleNew);
}}
onResetFilters={() => {
// 编辑器内左侧列表不展示筛选
}}
onRetry={onRetryPeers}
/>
<section className="lx-role-editor" aria-label="角色编辑器">
<header className="lx-role-editor__head">
<span className="lx-role-editor__avatar" aria-hidden="true">
<IconShield size={20} />
</span>
<div className="lx-role-editor__ident">
<div className="lx-role-editor__name">
<b>{headerTitle}</b>
<RoleStatusTag status={detail.status} />
{detail.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
</div>
<div className="lx-role-editor__meta">
<code>{detail.code}</code>
<i>·</i>
<span>{detail.userCount} 名用户</span>
<i>·</i>
<span>最近更新 {formatRoleDateTime(detail.updatedAt)}</span>
<i>·</i>
<span>{scopeLabelOf(detail.dataScope.type)}</span>
</div>
</div>
</header>
<div className="lx-role-editor__alert-wrap">
<EditorAlert
readonly={readonly}
locked={locked}
conflicts={conflicts}
saveError={saveError}
meta={meta}
/>
</div>
<RoleEditorTabs
detail={detail}
tree={tree}
treeLoading={treeLoading}
treeError={treeError}
onRetryTree={onRetryTree}
editor={editor}
locked={locked}
readonly={readonly}
activeTab={activeTab}
onChangeTab={setActiveTab}
errors={errors}
onSpecialRequest={toggleSpecial}
/>
<EditorFoot
readonly={readonly}
locked={locked}
conflicts={conflicts}
editor={editor}
saving={updateRole.isPending}
affectedUsers={detail.userCount}
onCancel={() => {
void navigate(ROUTES.system.roles);
}}
onSave={attemptSave}
/>
</section>
</div>
<DisableRoleDialog
key={`disable-${disableOpen ? detail.id : 'closed'}`}
visible={disableOpen}
role={detail}
loading={updateRole.isPending}
onCancel={() => {
setDisableOpen(false);
}}
onConfirm={(reason) => {
updateRole.mutate(
{
roleId: detail.id,
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
},
{
onSuccess: () => {
setDisableOpen(false);
notifySuccess(`「${detail.name}」已停用,${impactUsers} 名用户下一次请求生效`);
},
},
);
}}
/>
<SpecialPermissionDialog
visible={specialDialog !== null}
permission={specialDialog?.permission ?? null}
granted={specialDialog?.granted ?? false}
holderCount={impactUsers}
onCancel={() => {
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 ? (
<SpecialPermissionDialog
visible={specialPrompt}
permission={
tree?.specialPermissions.find((item) => item.code === specialChangedCodes[0]) ?? null
}
granted={editor.diff.addedSpecial.length > 0}
holderCount={impactUsers}
onCancel={() => {
setSpecialPrompt(false);
}}
onConfirm={() => {
setSpecialPrompt(false);
setSaveOpen(true);
}}
/>
) : null}
<SaveRoleDialog
visible={saveOpen}
role={detail}
diff={editor.diff}
meta={meta}
baseName={editor.base.name}
draftName={editor.draft.name}
baseScope={scopeLabelOf(editor.base.dataScope.type)}
draftScope={scopeLabelOf(editor.draft.dataScope.type)}
loading={updateRole.isPending}
onCancel={() => {
setSaveOpen(false);
}}
onConfirm={doSave}
/>
<LeaveRoleDialog
visible={blocker.state === 'blocked'}
roleName={detail.name}
onCancel={() => {
if (blocker.state === 'blocked') {
blocker.reset();
}
}}
onConfirm={() => {
if (blocker.state === 'blocked') {
blocker.proceed();
}
}}
/>
</>
);
}
function EditorLoading(): ReactNode {
return (
<div className="lx-roles__split">
<section className="lx-role-list lx-role-list--loading">
<PageSkeleton rows={9} />
</section>
<section className="lx-role-editor lx-role-editor--loading">
<PageSkeleton rows={10} />
</section>
</div>
);
}
/** 角色编辑器页(/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 <ErrorState title="角色不存在" message="链接缺少角色标识,请返回角色列表重新选择。" />;
}
if (detail.isPending) {
return (
<div className="lx-roles">
<EditorLoading />
</div>
);
}
if (!detail.isSuccess) {
return (
<div className="lx-roles">
<EditorLoading />
</div>
);
}
return (
<div className="lx-roles">
<EditorContent
key={detail.data.id}
detail={detail.data}
tree={tree.data}
treeLoading={tree.isPending}
treeError={tree.error}
onRetryTree={() => {
void tree.refetch();
}}
peers={peerRoles}
peersLoading={peers.isPending}
peersError={peers.error}
onRetryPeers={() => {
void peers.refetch();
}}
onSelectPeer={selectPeer}
canManage={canManage}
/>
</div>
);
}