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,40 @@
import { describe, expect, it } from 'vitest';
import { getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
describe('面包屑子路由补全', () => {
it('角色新建页:工作台 / 管理中心 / 角色权限 / 新建角色', () => {
const trail = getRoleSectionTrail('/system/roles/new');
expect(trail?.map((entry) => entry.label)).toEqual([
'工作台',
'管理中心',
'角色权限',
'新建角色',
]);
expect(trail?.[2]?.path).toBe('/system/roles');
});
it('角色编辑器:末级为角色编辑', () => {
const trail = getRoleSectionTrail('/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ');
expect(trail?.map((entry) => entry.label)).toEqual([
'工作台',
'管理中心',
'角色权限',
'角色编辑',
]);
});
it('角色列表页由导航配置派生,不走子路由补全', () => {
expect(getRoleSectionTrail('/system/roles')).toBeNull();
});
it('用户子路由补全不受影响', () => {
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
'工作台',
'管理中心',
'用户与组织',
'新建用户',
]);
expect(getUserSectionTrail('/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')).not.toBeNull();
});
});
+21
View File
@@ -2,6 +2,27 @@ import { ROUTES } from '@/routes';
import type { BreadcrumbEntry } from './nav-config';
/** 角色模块下的子路由(新建 / 编辑器)不在 nav-config 的菜单项里,这里补齐面包屑 */
export function getRoleSectionTrail(pathname: string): BreadcrumbEntry[] | null {
if (pathname === ROUTES.system.roleNew) {
return [
{ key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' },
{ key: 'roles', label: '角色权限', path: ROUTES.system.roles },
{ key: 'role-new', label: '新建角色' },
];
}
if (/^\/system\/roles\/[^/]+$/.test(pathname)) {
return [
{ key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' },
{ key: 'roles', label: '角色权限', path: ROUTES.system.roles },
{ key: 'role-detail', label: '角色编辑' },
];
}
return null;
}
/**
* 用户模块下的子路由(新建 / 详情)不在 nav-config 的菜单项里,
* 这里补齐面包屑:工作台 / 管理中心 / 用户与组织 / 当前页。
@@ -33,6 +33,17 @@ describe('导航配置', () => {
);
});
it('角色权限菜单使用 role:view,只读角色也能进入角色页', () => {
const rolesItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find(
(item) => item.key === 'roles',
);
expect(rolesItem?.permission).toBe('role:view');
expect(findActiveNav(NAV_MODULES, '/system/roles/new')?.item?.label).toBe('角色权限');
expect(findActiveNav(NAV_MODULES, '/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
'system',
);
});
it('按路径定位当前模块与菜单', () => {
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
expect(active?.module.key).toBe('material');
+1 -1
View File
@@ -259,7 +259,7 @@ export const NAV_MODULES: readonly NavModule[] = [
label: '角色权限',
path: '/system/roles',
icon: IconShield,
permission: Permission.ROLE_MANAGE,
permission: Permission.ROLE_VIEW,
description: '角色、权限点与职责互斥',
},
{