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 全绿。
This commit is contained in:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

@@ -0,0 +1,703 @@
/* 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>
);
}