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,396 @@
/* eslint-disable max-lines-per-function, complexity -- 列表页负责主从联动、筛选、菜单与五类弹窗编排,集中便于追踪数据流 */
import { Button, Input, Switch } from '@douyinfe/semi-ui-19';
import { Permission, type RoleDetail, type RoleListItem } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { IconAdd, IconSearch, IconShield } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { Can } from '@/components/Can';
import {
useCreateRoleMutation,
useDeleteRoleMutation,
usePermissionTreeQuery,
useRoleDetailQuery,
useRoleListQuery,
useUpdateRoleMutation,
} from '@/hooks/queries/use-roles';
import { usePermission } from '@/hooks/use-permission';
import { roleDetailPath, ROUTES } from '@/routes';
import { PermissionCatalogDrawer } from './PermissionCatalogDrawer';
import {
CopyRoleDialog,
DeleteRoleDialog,
DisableRoleDialog,
RenameRoleDialog,
} from './role-dialogs';
import {
DEFAULT_ROLE_LIST_FILTERS,
filterRoles,
hasActiveRoleFilters,
type RoleListFilters,
summarizeRoles,
} from './role-display';
import { RoleDetailPanel } from './RoleDetailPanel';
import { RoleListPanel, type RoleRowAction } from './RoleListPanel';
/** 角色管理列表查询口径:角色 ≤ 20,一次拉全量后本地筛选(分页由列表卡兜底) */
const ROLE_LIST_QUERY = {
limit: 100,
status: 'all',
sort: 'created_at',
order: 'desc',
} as const;
type DialogState = { kind: 'disable' | 'delete' | 'rename' | 'copy'; role: RoleListItem } | null;
function RoleFilterBar({
filters,
stats,
onChange,
onReset,
}: {
filters: RoleListFilters;
stats: ReturnType<typeof summarizeRoles>;
onChange: (next: Partial<RoleListFilters>) => void;
onReset: () => void;
}): ReactNode {
return (
<section className="lx-role-filters" aria-label="筛选角色">
<Input
className="lx-role-filters__keyword"
value={filters.keyword}
prefix={<IconSearch size={16} />}
placeholder="搜索角色名称或说明"
aria-label="搜索角色"
showClear
onChange={(value) => {
onChange({ keyword: value });
}}
/>
<div className="lx-seg" role="group" aria-label="角色状态">
{[
{ value: 'all' as const, label: '全部', count: stats.total },
{ value: 'active' as const, label: '启用', count: stats.active },
{ value: 'disabled' as const, label: '停用', count: stats.disabled },
].map((option) => (
<button
key={option.value}
type="button"
className={option.value === filters.status ? 'lx-seg__item is-active' : 'lx-seg__item'}
aria-pressed={option.value === filters.status}
onClick={() => {
onChange({ status: option.value });
}}
>
{option.label}
<i className="lx-role-filters__count">{option.count}</i>
</button>
))}
</div>
<label className="lx-role-filters__special">
<Switch
size="small"
checked={filters.specialOnly}
onChange={(checked) => {
onChange({ specialOnly: checked });
}}
/>
只看含特殊权限
</label>
<div className="lx-role-filters__stats" aria-label="角色统计">
<span className="lx-role-filters__chip">
<i>绑定用户</i>
<b>{stats.boundUsers}</b>
</span>
<span className="lx-role-filters__chip">
<i>特殊权限授予</i>
<b>{stats.specialGrants}</b>
</span>
{hasActiveRoleFilters(filters) ? (
<Button theme="borderless" size="small" onClick={onReset}>
清除筛选
</Button>
) : null}
</div>
</section>
);
}
/** 角色管理列表页:左角色列表 + 右只读详情面板(/system/roles) */
export function RolesPage(): ReactNode {
const navigate = useNavigate();
const canManage = usePermission(Permission.ROLE_MANAGE);
const tree = usePermissionTreeQuery();
const listQuery = useRoleListQuery(ROLE_LIST_QUERY);
const roles = useMemo(() => listQuery.data?.items ?? [], [listQuery.data]);
const [filters, setFilters] = useState<RoleListFilters>(DEFAULT_ROLE_LIST_FILTERS);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [dialog, setDialog] = useState<DialogState>(null);
const [catalogOpen, setCatalogOpen] = useState(false);
const filteredRoles = useMemo(() => filterRoles(roles, filters), [roles, filters]);
const stats = useMemo(() => summarizeRoles(roles), [roles]);
const effectiveSelectedId = filteredRoles.some((role) => role.id === selectedId)
? selectedId
: (filteredRoles[0]?.id ?? null);
const detail = useRoleDetailQuery(effectiveSelectedId);
const copySource = useRoleDetailQuery(dialog?.kind === 'copy' ? dialog.role.id : null);
const updateRole = useUpdateRoleMutation();
const deleteRole = useDeleteRoleMutation();
const createRole = useCreateRoleMutation();
const openDialog = (kind: 'disable' | 'delete' | 'rename' | 'copy', role: RoleListItem): void => {
setDialog({ kind, role });
};
const handleRowAction = (action: RoleRowAction, role: RoleListItem): void => {
setSelectedId(role.id);
if (action === 'view' || action === 'edit') {
void navigate(roleDetailPath(role.id));
return;
}
if (action === 'enable') {
updateRole.mutate(
{ roleId: role.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${role.name}」已启用,${String(role.userCount)} 名用户下一次请求生效`);
},
},
);
return;
}
openDialog(action, role);
};
const selectedDetail: RoleDetail | undefined = detail.data;
const activeRole = dialog?.role ?? selectedDetail ?? null;
return (
<div className="lx-roles">
<header className="lx-roles__header">
<div>
<h1>角色权限</h1>
<p>配置功能权限、数据范围与特殊权限,变更即时生效并留痕</p>
</div>
<div className="lx-roles__header-actions">
<Button
icon={<IconShield size={14} />}
onClick={() => {
setCatalogOpen(true);
}}
>
权限点目录
</Button>
<Can permission={Permission.ROLE_MANAGE}>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
onClick={() => {
void navigate(ROUTES.system.roleNew);
}}
>
新建角色
</Button>
</Can>
</div>
</header>
<RoleFilterBar
filters={filters}
stats={stats}
onChange={(next) => {
setFilters((current) => ({ ...current, ...next }));
}}
onReset={() => {
setFilters(DEFAULT_ROLE_LIST_FILTERS);
}}
/>
<div className="lx-roles__split">
<RoleListPanel
roles={filteredRoles}
totalCount={roles.length}
loading={listQuery.isPending}
error={listQuery.error}
selectedRoleId={effectiveSelectedId}
note={`内置 ${String(stats.systemCount)} · 自定义 ${String(stats.total - stats.systemCount)}`}
canManage={canManage}
showRowMenu
isFiltered={hasActiveRoleFilters(filters)}
onSelect={setSelectedId}
onRowAction={handleRowAction}
onCreate={() => {
void navigate(ROUTES.system.roleNew);
}}
onResetFilters={() => {
setFilters(DEFAULT_ROLE_LIST_FILTERS);
}}
onRetry={() => {
void listQuery.refetch();
}}
/>
<RoleDetailPanel
roleId={effectiveSelectedId}
detail={selectedDetail}
tree={tree.data}
loading={detail.isPending}
error={detail.error}
canManage={canManage}
onRetry={() => {
void detail.refetch();
}}
onEdit={(roleId) => {
void navigate(roleDetailPath(roleId));
}}
onCopy={(role) => {
openDialog('copy', role);
}}
onRename={(role) => {
openDialog('rename', role);
}}
onDisable={(role) => {
openDialog('disable', role);
}}
onEnable={(role) => {
updateRole.mutate(
{ roleId: role.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${role.name}」已启用`);
},
},
);
}}
onOpenCatalog={() => {
setCatalogOpen(true);
}}
/>
</div>
<DisableRoleDialog
key={`disable-${dialog?.kind === 'disable' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'disable'}
role={activeRole}
loading={updateRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(reason) => {
if (dialog === null) {
return;
}
updateRole.mutate(
{
roleId: dialog.role.id,
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
},
{
onSuccess: () => {
setDialog(null);
notifySuccess(
`「${dialog.role.name}」已停用,${String(dialog.role.userCount)} 名用户下一次请求生效`,
);
},
},
);
}}
/>
<DeleteRoleDialog
key={`delete-${dialog?.kind === 'delete' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'delete'}
role={activeRole}
loading={deleteRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={() => {
if (dialog === null) {
return;
}
deleteRole.mutate(dialog.role.id, {
onSuccess: () => {
setSelectedId(null);
setDialog(null);
notifySuccess('角色已删除');
},
});
}}
/>
<RenameRoleDialog
key={`rename-${dialog?.kind === 'rename' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'rename'}
role={activeRole}
loading={updateRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(name) => {
if (dialog === null) {
return;
}
updateRole.mutate(
{ roleId: dialog.role.id, input: { name } },
{
onSuccess: () => {
setDialog(null);
notifySuccess('角色名称已更新');
},
},
);
}}
/>
<CopyRoleDialog
key={`copy-${
dialog?.kind === 'copy'
? `${dialog.role.id}-${copySource.data?.id ?? 'loading'}`
: 'closed'
}`}
visible={dialog?.kind === 'copy'}
source={copySource.data ?? null}
sourceLoading={copySource.isPending}
loading={createRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(name) => {
const source = copySource.data;
if (source === undefined) {
return;
}
createRole.mutate(
{
name,
description: source.description,
permissions: [...source.permissions],
dataScope: {
type: source.dataScope.type,
...(source.dataScope.targetIds === undefined
? {}
: { targetIds: [...source.dataScope.targetIds] }),
},
},
{
onSuccess: (result) => {
setDialog(null);
notifySuccess('已创建「' + result.role.name + '」,可在编辑器中继续调整权限');
void navigate(roleDetailPath(result.role.id));
},
},
);
}}
/>
<PermissionCatalogDrawer
visible={catalogOpen}
tree={tree.data}
role={selectedDetail ?? null}
onClose={() => {
setCatalogOpen(false);
}}
/>
</div>
);
}