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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user