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
@@ -20,4 +20,6 @@ export const ENDPOINTS = {
|
||||
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
|
||||
departments: '/departments/tree',
|
||||
roles: '/roles',
|
||||
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
||||
permissions: '/permissions',
|
||||
} as const;
|
||||
+139
-2
@@ -85,7 +85,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 当前用户、角色、权限点、数据范围 */
|
||||
/** 当前用户、角色、权限点、数据范围(多角色并集) */
|
||||
get: operations['MeController_current'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -258,9 +258,61 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 角色列表(绑定用) */
|
||||
/**
|
||||
* 角色列表(游标分页 + 筛选 + 排序)
|
||||
* @description 默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围
|
||||
*/
|
||||
get: operations['RolesController_list'];
|
||||
put?: never;
|
||||
/**
|
||||
* 新建角色
|
||||
* @description 权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予
|
||||
*/
|
||||
post: operations['RolesController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/roles/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 角色详情(权限点、特殊权限、数据范围、绑定用户) */
|
||||
get: operations['RolesController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* 删除角色
|
||||
* @description 系统角色拒绝;有绑定用户(40903)只能停用
|
||||
*/
|
||||
delete: operations['RolesController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* 更新角色 / 启停
|
||||
* @description 系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示
|
||||
*/
|
||||
patch: operations['RolesController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/permissions': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 权限点目录(10 模块树 + 特殊权限 + SoD 对)
|
||||
* @description 模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予
|
||||
*/
|
||||
get: operations['PermissionsController_getTree'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -671,6 +723,91 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
RolesController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
RolesController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
RolesController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
RolesController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
PermissionsController_getTree: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -42,7 +42,7 @@ const me = {
|
||||
user: userSummary,
|
||||
roles: userSummary.roles,
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'all' },
|
||||
dataScopes: [{ type: 'all' }],
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RoleListQueryInput } from './roles';
|
||||
import type { UserListQueryInput } from './users';
|
||||
|
||||
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
|
||||
@@ -9,4 +10,7 @@ export const queryKeys = {
|
||||
userCustomers: (userId: string) => ['users', 'customers', userId] as const,
|
||||
departments: ['departments'] as const,
|
||||
roles: ['roles'] as const,
|
||||
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
||||
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
||||
permissions: ['permissions'] as const,
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import {
|
||||
createRole,
|
||||
deleteRole,
|
||||
fetchPermissionTree,
|
||||
fetchRoleDetail,
|
||||
fetchRoleList,
|
||||
toRoleListSearchParams,
|
||||
updateRole,
|
||||
} from './roles';
|
||||
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
|
||||
const roleListItem = {
|
||||
id: ROLE_ID,
|
||||
code: 'reviewer',
|
||||
name: '审核员',
|
||||
description: '脚本与素材审核',
|
||||
isSystem: false,
|
||||
status: 'active',
|
||||
permissionCount: 4,
|
||||
specialPermissionCount: 0,
|
||||
userCount: 3,
|
||||
dataScope: { type: 'department_and_children' },
|
||||
createdAt: '2026-10-27T02:40:00.000Z',
|
||||
updatedAt: '2026-10-27T02:40:00.000Z',
|
||||
};
|
||||
|
||||
const roleDetail = {
|
||||
...roleListItem,
|
||||
permissions: ['script:read', 'script:review', 'material:read', 'material:review'],
|
||||
specialPermissions: [],
|
||||
boundUsers: [
|
||||
{
|
||||
id: USER_ID,
|
||||
name: '赵一诺',
|
||||
account: 'zhao.yinuo',
|
||||
phoneMasked: '136****3188',
|
||||
departmentName: '内容生产中心 / 素材二组',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const permissionTree = {
|
||||
modules: [
|
||||
{
|
||||
module: 'script',
|
||||
moduleLabel: '脚本',
|
||||
sortOrder: 10,
|
||||
permissions: [
|
||||
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
|
||||
{ code: 'script:review', name: '审核脚本', description: '入库审核', isSpecial: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
module: 'delivery',
|
||||
moduleLabel: '导出交付',
|
||||
sortOrder: 40,
|
||||
permissions: [
|
||||
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
|
||||
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
specialPermissions: [
|
||||
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||
],
|
||||
sodPairs: [
|
||||
{
|
||||
id: 'script-write-review',
|
||||
label: '脚本生成 × 脚本审核',
|
||||
description: '同一角色不得同时拥有',
|
||||
permissions: ['script:write', 'script:review'],
|
||||
},
|
||||
],
|
||||
totalPermissions: 4,
|
||||
};
|
||||
|
||||
const saveResult = {
|
||||
role: roleDetail,
|
||||
affectedUserCount: 3,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
function toUrl(input: string | URL | Request | undefined): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
return input?.url ?? '';
|
||||
}
|
||||
|
||||
function bodyOf(init: RequestInit | undefined): unknown {
|
||||
return typeof init?.body === 'string' ? JSON.parse(init.body) : null;
|
||||
}
|
||||
|
||||
describe('角色列表请求参数', () => {
|
||||
it('按后端 query 约定拼装 snake_case 参数,空关键词不传', () => {
|
||||
const params = toRoleListSearchParams({
|
||||
limit: 100,
|
||||
status: 'all',
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
keyword: ' 审核 ',
|
||||
});
|
||||
expect(params.toString()).toBe(
|
||||
'limit=100&keyword=%E5%AE%A1%E6%A0%B8&status=all&sort=created_at&order=desc',
|
||||
);
|
||||
const empty = toRoleListSearchParams({ limit: 20, status: 'all', keyword: '' });
|
||||
expect(empty.has('keyword')).toBe(false);
|
||||
expect(empty.has('cursor')).toBe(false);
|
||||
});
|
||||
|
||||
it('带游标时完整输出', () => {
|
||||
const params = toRoleListSearchParams({ limit: 20, status: 'disabled', cursor: 'cursor-2' });
|
||||
expect(params.get('cursor')).toBe('cursor-2');
|
||||
expect(params.get('status')).toBe('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色 API 客户端', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: ROLE_ID, name: '灵犀管理员', account: 'admin' },
|
||||
permissions: ['role:view'],
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('解析角色列表信封与详情', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [roleListItem], next_cursor: 'cursor-2' }))
|
||||
.mockResolvedValueOnce(jsonResponse(roleDetail));
|
||||
|
||||
const list = await fetchRoleList({ limit: 100, status: 'all' });
|
||||
expect(list.items).toHaveLength(1);
|
||||
expect(list.next_cursor).toBe('cursor-2');
|
||||
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/roles?limit=100&status=all');
|
||||
|
||||
const detail = await fetchRoleDetail(ROLE_ID);
|
||||
expect(detail.permissions).toContain('script:review');
|
||||
expect(detail.boundUsers[0]?.name).toBe('赵一诺');
|
||||
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/roles/${ROLE_ID}`);
|
||||
});
|
||||
|
||||
it('解析权限点目录', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(permissionTree));
|
||||
const tree = await fetchPermissionTree();
|
||||
expect(tree.totalPermissions).toBe(4);
|
||||
expect(tree.specialPermissions[0]?.code).toBe('delivery:export');
|
||||
});
|
||||
|
||||
it('新建角色以 camelCase body 提交并解析保存结果', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
|
||||
const result = await createRole({
|
||||
name: '审核员',
|
||||
description: '脚本与素材审核',
|
||||
permissions: ['script:read', 'script:review'],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
});
|
||||
expect(result.affectedUserCount).toBe(3);
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(bodyOf(init)).toEqual({
|
||||
name: '审核员',
|
||||
description: '脚本与素材审核',
|
||||
permissions: ['script:read', 'script:review'],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
});
|
||||
});
|
||||
|
||||
it('更新角色状态与停用原因', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
|
||||
await updateRole(ROLE_ID, { status: 'disabled', reason: '岗位调整' });
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
expect(init?.method).toBe('PATCH');
|
||||
expect(bodyOf(init)).toEqual({ status: 'disabled', reason: '岗位调整' });
|
||||
});
|
||||
|
||||
it('删除角色返回 204', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
await expect(deleteRole(ROLE_ID)).resolves.toBeUndefined();
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('响应用共享 schema 校验:非法权限点编码直接报错', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
jsonResponse({ ...roleDetail, permissions: ['ghost:permission'] }),
|
||||
);
|
||||
await expect(fetchRoleDetail(ROLE_ID)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
type CreateRoleInput,
|
||||
createRoleInputSchema,
|
||||
type PermissionTree,
|
||||
permissionTreeSchema,
|
||||
type RoleDetail,
|
||||
roleDetailSchema,
|
||||
type RoleListQuery,
|
||||
type RoleListResponse,
|
||||
roleListResponseSchema,
|
||||
type RoleSaveResult,
|
||||
roleSaveResultSchema,
|
||||
type UpdateRoleInput,
|
||||
updateRoleInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
import { request } from './client';
|
||||
import { ENDPOINTS } from './endpoints';
|
||||
|
||||
/** 角色管理列表查询(query 参数 snake_case;角色管理页始终传全量状态) */
|
||||
export interface RoleListQueryInput extends Partial<RoleListQuery> {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
/** 排除指定角色 id(模板 / 复制来源场景由前端过滤,接口不感知) */
|
||||
excludeRoleId?: string;
|
||||
}
|
||||
|
||||
/** 组装 GET /roles 的 query 参数;空值不落 query,避免后端严格校验报错 */
|
||||
export function toRoleListSearchParams(query: RoleListQueryInput): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(query.limit));
|
||||
if (query.cursor !== undefined && query.cursor !== '') {
|
||||
params.set('cursor', query.cursor);
|
||||
}
|
||||
if (query.keyword !== undefined && query.keyword.trim() !== '') {
|
||||
params.set('keyword', query.keyword.trim());
|
||||
}
|
||||
if (query.status !== undefined) {
|
||||
params.set('status', query.status);
|
||||
}
|
||||
if (query.sort !== undefined) {
|
||||
params.set('sort', query.sort);
|
||||
}
|
||||
if (query.order !== undefined) {
|
||||
params.set('order', query.order);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function fetchRoleList(query: RoleListQueryInput): Promise<RoleListResponse> {
|
||||
const search = toRoleListSearchParams(query).toString();
|
||||
const path = search === '' ? ENDPOINTS.roles : `${ENDPOINTS.roles}?${search}`;
|
||||
const payload: unknown = await request(path);
|
||||
return roleListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchRoleDetail(roleId: string): Promise<RoleDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.role(roleId));
|
||||
return roleDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
/** GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD(与后端同源) */
|
||||
export async function fetchPermissionTree(): Promise<PermissionTree> {
|
||||
const payload: unknown = await request(ENDPOINTS.permissions);
|
||||
return permissionTreeSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function createRole(input: CreateRoleInput): Promise<RoleSaveResult> {
|
||||
const body = createRoleInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.roles, { method: 'POST', body });
|
||||
return roleSaveResultSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSaveResult> {
|
||||
const body = updateRoleInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.role(roleId), { method: 'PATCH', body });
|
||||
return roleSaveResultSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function deleteRole(roleId: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.role(roleId), { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
CreateRoleInput,
|
||||
PermissionTree,
|
||||
RoleDetail,
|
||||
RoleListResponse,
|
||||
RoleSaveResult,
|
||||
UpdateRoleInput,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
type UseMutationResult,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
import {
|
||||
createRole,
|
||||
deleteRole,
|
||||
fetchPermissionTree,
|
||||
fetchRoleDetail,
|
||||
fetchRoleList,
|
||||
type RoleListQueryInput,
|
||||
updateRole,
|
||||
} from '@/api/roles';
|
||||
|
||||
export function useRoleListQuery(query: RoleListQueryInput): UseQueryResult<RoleListResponse> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.roleList(query),
|
||||
queryFn: () => fetchRoleList(query),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoleDetailQuery(roleId: string | null): UseQueryResult<RoleDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.role(roleId ?? ''),
|
||||
queryFn: () => fetchRoleDetail(roleId ?? ''),
|
||||
enabled: roleId !== null && roleId !== '',
|
||||
});
|
||||
}
|
||||
|
||||
/** 权限点目录(10 模块 / 30 点 / 6 特殊 / 3 对 SoD):登录后长期复用 */
|
||||
export function usePermissionTreeQuery(): UseQueryResult<PermissionTree> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.permissions,
|
||||
queryFn: fetchPermissionTree,
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateRoles(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
return async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.roles });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateRoleMutation(): UseMutationResult<RoleSaveResult, Error, CreateRoleInput> {
|
||||
const invalidate = useInvalidateRoles();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateRoleInput) => createRole(input),
|
||||
onSuccess: async (result) => {
|
||||
await invalidate();
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRoleMutation(): UseMutationResult<
|
||||
RoleSaveResult,
|
||||
Error,
|
||||
{ roleId: string; input: UpdateRoleInput }
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ roleId, input }: { roleId: string; input: UpdateRoleInput }) =>
|
||||
updateRole(roleId, input),
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData(queryKeys.role(result.role.id), result.role);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRoleMutation(): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (roleId: string) => deleteRole(roleId),
|
||||
onSuccess: (_result, roleId) => {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.role(roleId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getUserSectionTrail } from '@/navigation/breadcrumbs';
|
||||
import { getRoleSectionTrail, getUserSectionTrail } from '@/navigation/breadcrumbs';
|
||||
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
|
||||
|
||||
export interface PageBreadcrumbsProps {
|
||||
@@ -9,7 +9,10 @@ export interface PageBreadcrumbsProps {
|
||||
}
|
||||
|
||||
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
|
||||
const entries = getUserSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname);
|
||||
const entries =
|
||||
getUserSectionTrail(pathname) ??
|
||||
getRoleSectionTrail(pathname) ??
|
||||
getBreadcrumbs(NAV_MODULES, pathname);
|
||||
return (
|
||||
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
||||
{entries.map((entry, index) => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import '@/styles/global.css';
|
||||
import '@/styles/layout.css';
|
||||
import '@/styles/pages.css';
|
||||
import '@/styles/users.css';
|
||||
import '@/styles/roles.css';
|
||||
import '@/styles/role-editor.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '角色、权限点与职责互斥',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ function meWith(mustChangePassword: boolean): MeResponse {
|
||||
},
|
||||
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
|
||||
permissions: ['user:view', 'user:manage'],
|
||||
dataScope: { type: 'all' },
|
||||
dataScopes: [{ type: 'all' }],
|
||||
mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
|
||||
import { Button, SideSheet } from '@douyinfe/semi-ui-19';
|
||||
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||
import { IconCheck, IconStar } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { scopeLabelOf } from './role-display';
|
||||
|
||||
export interface PermissionCatalogDrawerProps {
|
||||
visible: boolean;
|
||||
tree: PermissionTree | undefined;
|
||||
role: RoleDetail | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 权限点目录抽屉:只读展示 30 个权限点与当前角色的持有情况 */
|
||||
export function PermissionCatalogDrawer({
|
||||
visible,
|
||||
tree,
|
||||
role,
|
||||
onClose,
|
||||
}: PermissionCatalogDrawerProps): ReactNode {
|
||||
const granted = new Set<string>([
|
||||
...(role?.permissions ?? []),
|
||||
...(role?.specialPermissions ?? []),
|
||||
]);
|
||||
return (
|
||||
<SideSheet
|
||||
visible={visible}
|
||||
title="权限点目录"
|
||||
width={480}
|
||||
placement="right"
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div className="lx-role-catalog__foot">
|
||||
<span>权限点变更即时对绑定用户生效</span>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-catalog">
|
||||
<p className="lx-role-catalog__sub">
|
||||
{tree?.totalPermissions ?? 30} 个权限点 · {tree?.specialPermissions.length ?? 6}{' '}
|
||||
个特殊权限;★ 默认关闭、需单独授予。
|
||||
{role === null
|
||||
? ' 当前角色尚未加载。'
|
||||
: ` 当前勾选为「${role.name}」持有(${String(role.permissionCount + role.specialPermissionCount)} 个)。`}
|
||||
</p>
|
||||
{tree === undefined ? (
|
||||
<p className="lx-role-catalog__empty">权限点目录加载中…</p>
|
||||
) : (
|
||||
tree.modules.map((module) => (
|
||||
<section key={module.module} className="lx-role-catalog__module">
|
||||
<header className="lx-role-catalog__module-head">
|
||||
<b>{module.moduleLabel}</b>
|
||||
<span>
|
||||
{module.permissions.filter((permission) => granted.has(permission.code)).length} /{' '}
|
||||
{module.permissions.length}
|
||||
</span>
|
||||
</header>
|
||||
{module.permissions.map((permission) => {
|
||||
const owned = granted.has(permission.code);
|
||||
return (
|
||||
<div
|
||||
key={permission.code}
|
||||
className={owned ? 'lx-role-catalog__row is-granted' : 'lx-role-catalog__row'}
|
||||
>
|
||||
<span className="lx-role-catalog__check" aria-hidden="true">
|
||||
{owned ? <IconCheck size={12} /> : null}
|
||||
</span>
|
||||
<span className="lx-role-catalog__copy">
|
||||
<span className="lx-role-catalog__name">
|
||||
{permission.name}
|
||||
{permission.isSpecial ? (
|
||||
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="lx-role-catalog__desc">{permission.description}</span>
|
||||
</span>
|
||||
<code className="lx-role-catalog__code">{permission.code}</code>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
<div className="lx-role-catalog__note">
|
||||
<IconStar size={14} />
|
||||
<span>
|
||||
特殊权限 6 项与普通权限分开授予,不随角色复制带入;当前角色数据范围为「
|
||||
{role === null ? '—' : scopeLabelOf(role.dataScope.type)}」。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
permissionsByModule,
|
||||
permissionTreeSchema,
|
||||
roleDetailSchema,
|
||||
SOD_PAIRS,
|
||||
} from '@lingxi/shared';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RoleDetailPanel } from './RoleDetailPanel';
|
||||
|
||||
vi.mock('@lingxi/ui', () => ({
|
||||
PageEmpty: ({ title }: { title?: string }) => <div data-testid="page-empty">{title}</div>,
|
||||
ErrorState: ({ title }: { title?: string }) => <div data-testid="error-state">{title}</div>,
|
||||
PageSkeleton: () => <div data-testid="page-skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock('@douyinfe/semi-ui-19', () => ({
|
||||
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
|
||||
function roleNode(permission: (typeof PERMISSION_CATALOG)[number]): {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isSpecial: boolean;
|
||||
} {
|
||||
return {
|
||||
code: permission.code,
|
||||
name: permission.name,
|
||||
description: permission.description,
|
||||
isSpecial: permission.isSpecial,
|
||||
};
|
||||
}
|
||||
|
||||
const tree: PermissionTree = permissionTreeSchema.parse({
|
||||
modules: permissionsByModule().map((module) => ({
|
||||
module: module.module,
|
||||
moduleLabel: module.moduleLabel,
|
||||
sortOrder: module.sortOrder,
|
||||
permissions: module.permissions.map(roleNode),
|
||||
})),
|
||||
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(roleNode),
|
||||
sodPairs: SOD_PAIRS.map((pair) => ({
|
||||
id: pair.id,
|
||||
label: pair.label,
|
||||
description: pair.description,
|
||||
permissions: [pair.permissions[0], pair.permissions[1]],
|
||||
})),
|
||||
totalPermissions: PERMISSION_CATALOG.length,
|
||||
});
|
||||
|
||||
const detail: RoleDetail = roleDetailSchema.parse({
|
||||
id: ROLE_ID,
|
||||
code: 'account_owner',
|
||||
name: '客户负责人',
|
||||
description: '客户资料、脚本审核与交付验收',
|
||||
isSystem: false,
|
||||
status: 'active',
|
||||
permissionCount: 2,
|
||||
specialPermissionCount: 1,
|
||||
userCount: 4,
|
||||
dataScope: { type: 'customer_groups', targetIds: [] },
|
||||
permissions: ['script:read', 'script:review'],
|
||||
specialPermissions: ['delivery:export'],
|
||||
boundUsers: [
|
||||
{
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZ3',
|
||||
name: '陈默',
|
||||
account: 'chen.mo',
|
||||
phoneMasked: '138****2046',
|
||||
departmentName: '内容生产中心 / 素材一组',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZ4',
|
||||
name: '高岑',
|
||||
account: 'gao.cen',
|
||||
phoneMasked: '186****4402',
|
||||
departmentName: '交付中心 / 客户成功组',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZ5',
|
||||
name: '江岚',
|
||||
account: 'jiang.lan',
|
||||
phoneMasked: '159****3320',
|
||||
departmentName: '平台技术部 / 平台运维组',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZ6',
|
||||
name: '何岸',
|
||||
account: 'he.an',
|
||||
phoneMasked: '135****6641',
|
||||
departmentName: '交付中心 / 客户成功组',
|
||||
status: 'disabled',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-10-25T01:30:00.000Z',
|
||||
updatedAt: '2026-10-25T01:30:00.000Z',
|
||||
});
|
||||
|
||||
function renderPanel(overrides: Partial<Parameters<typeof RoleDetailPanel>[0]> = {}): void {
|
||||
render(
|
||||
<RoleDetailPanel
|
||||
roleId={ROLE_ID}
|
||||
detail={detail}
|
||||
tree={tree}
|
||||
loading={false}
|
||||
error={null}
|
||||
canManage
|
||||
onRetry={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDisable={vi.fn()}
|
||||
onEnable={vi.fn()}
|
||||
onOpenCatalog={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('角色详情面板', () => {
|
||||
it('渲染头部、权限摘要、数据范围与特殊权限', () => {
|
||||
renderPanel();
|
||||
expect(screen.getByText('客户负责人')).toBeTruthy();
|
||||
expect(screen.getByText('启用')).toBeTruthy();
|
||||
expect(screen.getByText('account_owner')).toBeTruthy();
|
||||
expect(screen.getByText('指定客户组')).toBeTruthy();
|
||||
expect(screen.getByText('发起导出')).toBeTruthy();
|
||||
expect(document.querySelector('.lx-role-detail__module-count')?.textContent).toContain('2 / 3');
|
||||
expect(document.querySelector('.lx-role-detail__meta')?.textContent).toContain('4 名');
|
||||
});
|
||||
|
||||
it('绑定用户默认展示前 3 名,可展开全部', () => {
|
||||
renderPanel();
|
||||
expect(screen.getByText('陈默')).toBeTruthy();
|
||||
expect(screen.queryByText('何岸')).toBeNull();
|
||||
fireEvent.click(screen.getByText('查看全部 4 名'));
|
||||
expect(screen.getByText('何岸')).toBeTruthy();
|
||||
expect(screen.getByText('收起')).toBeTruthy();
|
||||
expect(screen.queryByText('查看全部 4 名')).toBeNull();
|
||||
});
|
||||
|
||||
it('无管理权限时不渲染复制 / 停用等写控件', () => {
|
||||
renderPanel({ canManage: false });
|
||||
expect(screen.queryByText('复制')).toBeNull();
|
||||
expect(screen.queryByText('停用')).toBeNull();
|
||||
expect(screen.getByText('编辑角色')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('未选择角色时展示空态', () => {
|
||||
renderPanel({ roleId: null, detail: undefined });
|
||||
expect(screen.getByTestId('page-empty')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('详情加载失败时展示错误态并可重试', () => {
|
||||
renderPanel({ detail: undefined, error: new Error('boom') });
|
||||
expect(screen.getByTestId('error-state').textContent).toContain('角色详情加载失败');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/* eslint-disable max-lines-per-function -- 详情面板按头部/摘要/范围/特殊/绑定用户分区,集中便于对齐原型 */
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||
import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconCopy, IconEdit, IconPause, IconPlay, IconShield, IconStar } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
buildPermissionMetaMap,
|
||||
formatRoleDateTime,
|
||||
permissionNameOf,
|
||||
scopeDescriptionOf,
|
||||
scopeLabelOf,
|
||||
summarizePermissionModules,
|
||||
} from './role-display';
|
||||
import { RoleStatusTag } from './RoleStatusTag';
|
||||
|
||||
export interface RoleDetailPanelProps {
|
||||
roleId: string | null;
|
||||
detail: RoleDetail | undefined;
|
||||
tree: PermissionTree | undefined;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
canManage: boolean;
|
||||
onRetry: () => void;
|
||||
onEdit: (roleId: string) => void;
|
||||
onCopy: (role: RoleDetail) => void;
|
||||
onRename: (role: RoleDetail) => void;
|
||||
onDisable: (role: RoleDetail) => void;
|
||||
onEnable: (role: RoleDetail) => void;
|
||||
onOpenCatalog: (role: RoleDetail) => void;
|
||||
}
|
||||
|
||||
function RoleAvatar({ status }: { status: 'active' | 'disabled' }): ReactNode {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
status === 'active' ? 'lx-role-detail__avatar' : 'lx-role-detail__avatar is-disabled'
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconShield size={22} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHead({
|
||||
role,
|
||||
canManage,
|
||||
onCopy,
|
||||
onRename,
|
||||
onDisable,
|
||||
onEnable,
|
||||
}: Pick<RoleDetailPanelProps, 'canManage' | 'onCopy' | 'onRename' | 'onDisable' | 'onEnable'> & {
|
||||
role: RoleDetail;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<header className="lx-role-detail__head">
|
||||
<RoleAvatar status={role.status} />
|
||||
<div className="lx-role-detail__ident">
|
||||
<div className="lx-role-detail__name">
|
||||
<h2>{role.name}</h2>
|
||||
<RoleStatusTag status={role.status} />
|
||||
{role.isSystem ? <span className="lx-role-tag lx-role-tag--builtin">内置</span> : null}
|
||||
</div>
|
||||
<p className="lx-role-detail__desc">{role.description}</p>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="lx-role-detail__actions">
|
||||
<Button
|
||||
icon={<IconCopy size={14} />}
|
||||
onClick={() => {
|
||||
onCopy(role);
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
{role.isSystem ? (
|
||||
<Button
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
onRename(role);
|
||||
}}
|
||||
>
|
||||
改名
|
||||
</Button>
|
||||
) : role.status === 'active' ? (
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconPause size={14} />}
|
||||
onClick={() => {
|
||||
onDisable(role);
|
||||
}}
|
||||
>
|
||||
停用
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon={<IconPlay size={14} />}
|
||||
onClick={() => {
|
||||
onEnable(role);
|
||||
}}
|
||||
>
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionSummary({
|
||||
role,
|
||||
tree,
|
||||
onOpenCatalog,
|
||||
}: {
|
||||
role: RoleDetail;
|
||||
tree: PermissionTree | undefined;
|
||||
onOpenCatalog: () => void;
|
||||
}): ReactNode {
|
||||
const selected = [...role.permissions, ...role.specialPermissions];
|
||||
const modules = tree === undefined ? [] : summarizePermissionModules(tree, selected);
|
||||
return (
|
||||
<section className="lx-role-detail__section">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>权限点摘要</h3>
|
||||
<span className="lx-role-detail__count">
|
||||
已选 {role.permissionCount + role.specialPermissionCount} /{' '}
|
||||
{tree?.totalPermissions ?? '—'}
|
||||
</span>
|
||||
<Button theme="borderless" size="small" onClick={onOpenCatalog}>
|
||||
查看权限点目录
|
||||
</Button>
|
||||
</div>
|
||||
{modules.length === 0 ? (
|
||||
<p className="lx-role-detail__muted">权限点目录加载中…</p>
|
||||
) : (
|
||||
<div className="lx-role-detail__modules">
|
||||
{modules.map((module) => (
|
||||
<div
|
||||
key={module.module}
|
||||
className={
|
||||
module.selected === 0 ? 'lx-role-detail__module is-zero' : 'lx-role-detail__module'
|
||||
}
|
||||
>
|
||||
<span className="lx-role-detail__module-name">{module.moduleLabel}</span>
|
||||
<span className="lx-role-detail__module-count">
|
||||
<b>{module.selected}</b> / {module.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeAndSpecial({
|
||||
role,
|
||||
tree,
|
||||
}: {
|
||||
role: RoleDetail;
|
||||
tree: PermissionTree | undefined;
|
||||
}): ReactNode {
|
||||
const meta = tree === undefined ? undefined : buildPermissionMetaMap(tree);
|
||||
return (
|
||||
<section className="lx-role-detail__section">
|
||||
<div className="lx-role-detail__cols">
|
||||
<div className="lx-role-detail__col">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>数据范围</h3>
|
||||
</div>
|
||||
<p className="lx-role-detail__scope">
|
||||
<b>{scopeLabelOf(role.dataScope.type)}</b>
|
||||
<span>{scopeDescriptionOf(role.dataScope.type)}</span>
|
||||
</p>
|
||||
{role.dataScope.type === 'customer_groups' ? (
|
||||
<p className="lx-role-detail__muted">
|
||||
{(role.dataScope.targetIds ?? []).length === 0
|
||||
? '暂未指定客户组,客户组将在客户模块上线后可选择。'
|
||||
: `已指定 ${String((role.dataScope.targetIds ?? []).length)} 个客户组,客户组名称将在客户模块上线后展示。`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lx-role-detail__col">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>特殊权限</h3>
|
||||
<span className="lx-role-detail__count">{role.specialPermissions.length} / 6</span>
|
||||
</div>
|
||||
{role.specialPermissions.length === 0 ? (
|
||||
<p className="lx-role-detail__muted">
|
||||
未授予特殊权限,6 项均默认关闭,需单独授予并记录审计。
|
||||
</p>
|
||||
) : (
|
||||
<div className="lx-role-detail__special-chips">
|
||||
{role.specialPermissions.map((code) => (
|
||||
<span key={code} className="lx-role-detail__special-chip" title={code}>
|
||||
<IconStar size={12} />
|
||||
{meta === undefined ? code : permissionNameOf(meta, code)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BoundUsers({ role }: { role: RoleDetail }): ReactNode {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? role.boundUsers : role.boundUsers.slice(0, 3);
|
||||
return (
|
||||
<section className="lx-role-detail__section">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>绑定用户</h3>
|
||||
{role.boundUsers.length > 3 ? (
|
||||
<Button
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpanded((value) => !value);
|
||||
}}
|
||||
>
|
||||
{expanded ? '收起' : `查看全部 ${String(role.boundUsers.length)} 名`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{role.boundUsers.length === 0 ? (
|
||||
<p className="lx-role-detail__muted">暂未绑定用户,可停用或删除该角色。</p>
|
||||
) : (
|
||||
<ul className="lx-role-detail__users">
|
||||
{shown.map((user) => (
|
||||
<li key={user.id} className="lx-role-detail__user">
|
||||
<span
|
||||
className={
|
||||
user.status === 'disabled'
|
||||
? 'lx-role-detail__user-avatar is-disabled'
|
||||
: 'lx-role-detail__user-avatar'
|
||||
}
|
||||
>
|
||||
{user.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="lx-role-detail__user-name">{user.name}</span>
|
||||
<span className="lx-role-detail__user-account">{user.account}</span>
|
||||
<span className="lx-role-detail__user-dept">{user.departmentName}</span>
|
||||
<span
|
||||
className={
|
||||
user.status === 'active' ? 'lx-status-tag is-active' : 'lx-status-tag is-disabled'
|
||||
}
|
||||
>
|
||||
{user.status === 'active' ? '在职' : '停用'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailState({
|
||||
roleId,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
}: Pick<RoleDetailPanelProps, 'roleId' | 'loading' | 'error' | 'onRetry'>): ReactNode {
|
||||
if (roleId === null) {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="请选择一个角色"
|
||||
description="左侧列表支持搜索与状态筛选,选中角色后在此查看权限详情。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="lx-role-detail__state">
|
||||
<PageSkeleton rows={8} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error !== null) {
|
||||
return (
|
||||
<ErrorState
|
||||
className="lx-role-detail__state"
|
||||
title="角色详情加载失败"
|
||||
message={error.message}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 右侧角色详情面板(只读):权限摘要、数据范围、特殊权限、绑定用户与编辑入口 */
|
||||
export function RoleDetailPanel(props: RoleDetailPanelProps): ReactNode {
|
||||
const ready = props.roleId !== null && props.detail !== undefined && props.error === null;
|
||||
const detail = props.detail;
|
||||
return (
|
||||
<section className="lx-role-detail" aria-label="角色详情">
|
||||
{ready && detail !== undefined ? (
|
||||
<>
|
||||
<DetailHead
|
||||
role={detail}
|
||||
canManage={props.canManage}
|
||||
onCopy={props.onCopy}
|
||||
onRename={props.onRename}
|
||||
onDisable={props.onDisable}
|
||||
onEnable={props.onEnable}
|
||||
/>
|
||||
<div className="lx-role-detail__body">
|
||||
<div className="lx-role-detail__meta">
|
||||
<span>
|
||||
<em>编码</em>
|
||||
<code>{detail.code}</code>
|
||||
</span>
|
||||
<span>
|
||||
<em>绑定用户</em>
|
||||
{detail.userCount} 名
|
||||
</span>
|
||||
<span>
|
||||
<em>最近更新</em>
|
||||
{formatRoleDateTime(detail.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<PermissionSummary
|
||||
role={detail}
|
||||
tree={props.tree}
|
||||
onOpenCatalog={() => {
|
||||
props.onOpenCatalog(detail);
|
||||
}}
|
||||
/>
|
||||
<ScopeAndSpecial role={detail} tree={props.tree} />
|
||||
<BoundUsers role={detail} />
|
||||
</div>
|
||||
<footer className="lx-role-detail__foot">
|
||||
<span className="lx-role-detail__foot-note">
|
||||
{detail.isSystem ? '系统内置角色,不可停用或删除' : '自定义角色,可复制、停用或删除'}
|
||||
</span>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
props.onEdit(detail.id);
|
||||
}}
|
||||
>
|
||||
编辑角色
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<div className="lx-role-detail__empty">
|
||||
<DetailState
|
||||
roleId={props.roleId}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
onRetry={props.onRetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/* eslint-disable max-lines-per-function -- 编辑器四个 Tab 的内容区集中导出,便于与设计稿逐区对照 */
|
||||
import { Input, Tabs, TextArea } from '@douyinfe/semi-ui-19';
|
||||
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||
import { IconAlert, IconLock } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { formatRoleDateTime } from './role-display';
|
||||
import type { RoleDraftErrors } from './role-utils';
|
||||
import { RolePermissionTree } from './RolePermissionTree';
|
||||
import { RoleScopePicker } from './RoleScopePicker';
|
||||
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
|
||||
import { RoleStatusTag } from './RoleStatusTag';
|
||||
import type { RoleEditorState } from './use-role-editor';
|
||||
|
||||
export type RoleEditorTabKey = 'basic' | 'permissions' | 'scope' | 'special';
|
||||
|
||||
export interface RoleEditorTabsProps {
|
||||
detail: RoleDetail;
|
||||
tree: PermissionTree | undefined;
|
||||
treeLoading: boolean;
|
||||
treeError: Error | null;
|
||||
onRetryTree: () => void;
|
||||
editor: RoleEditorState;
|
||||
/** 系统角色:权限 / 范围 / 特殊权限锁定,仅显示名可改 */
|
||||
locked: boolean;
|
||||
/** 无 role:manage:所有写控件只读 */
|
||||
readonly: boolean;
|
||||
activeTab: RoleEditorTabKey;
|
||||
onChangeTab: (key: RoleEditorTabKey) => void;
|
||||
errors: RoleDraftErrors;
|
||||
onSpecialRequest: (code: string, granted: boolean) => void;
|
||||
}
|
||||
|
||||
function FieldError({ message }: { message: string | undefined }): ReactNode {
|
||||
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
|
||||
}
|
||||
|
||||
function BasicTab({
|
||||
detail,
|
||||
editor,
|
||||
readonly,
|
||||
locked,
|
||||
errors,
|
||||
}: {
|
||||
detail: RoleDetail;
|
||||
editor: RoleEditorState;
|
||||
readonly: boolean;
|
||||
locked: boolean;
|
||||
errors: RoleDraftErrors;
|
||||
}): ReactNode {
|
||||
const nameLocked = readonly;
|
||||
const descLocked = readonly || locked;
|
||||
return (
|
||||
<div className="lx-role-editor__basic">
|
||||
<section className="lx-role-editor__block">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>基本信息</h3>
|
||||
<span>用于列表展示、成员分配与审计检索</span>
|
||||
</header>
|
||||
<div className="lx-role-editor__grid">
|
||||
<label className="lx-role-field">
|
||||
<span className="lx-role-field__label">
|
||||
角色名称
|
||||
<i className="lx-role-field__required">*</i>
|
||||
<span className="lx-role-field__hint">{editor.draft.name.length}/64</span>
|
||||
</span>
|
||||
<Input
|
||||
value={editor.draft.name}
|
||||
maxLength={64}
|
||||
placeholder="例如:审核员"
|
||||
disabled={nameLocked}
|
||||
onChange={editor.setName}
|
||||
/>
|
||||
<FieldError message={errors.name} />
|
||||
</label>
|
||||
<label className="lx-role-field">
|
||||
<span className="lx-role-field__label">角色编码</span>
|
||||
<span className="lx-role-field__readonly">
|
||||
<IconLock size={13} />
|
||||
{detail.code}
|
||||
</span>
|
||||
<span className="lx-role-field__sub">创建后不可修改,接口与审计使用该编码</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="lx-role-field">
|
||||
<span className="lx-role-field__label">
|
||||
角色说明
|
||||
<span className="lx-role-field__hint">{editor.draft.description.length}/255</span>
|
||||
</span>
|
||||
<TextArea
|
||||
value={editor.draft.description}
|
||||
maxLength={255}
|
||||
autosize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="一句话说明该角色的职责"
|
||||
disabled={descLocked}
|
||||
onChange={editor.setDescription}
|
||||
/>
|
||||
<FieldError message={errors.description} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="lx-role-editor__block">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>绑定用户</h3>
|
||||
<span>停用或删除角色前请先转交</span>
|
||||
</header>
|
||||
<div className="lx-role-editor__bind-head">
|
||||
<b>{detail.userCount} 名用户</b>
|
||||
<span>绑定后立即获得该角色的权限与数据范围</span>
|
||||
</div>
|
||||
{detail.boundUsers.length === 0 ? (
|
||||
<p className="lx-role-editor__muted">暂未绑定用户</p>
|
||||
) : (
|
||||
<ul className="lx-role-editor__users">
|
||||
{detail.boundUsers.map((user) => (
|
||||
<li key={user.id} className="lx-role-editor__user">
|
||||
<span
|
||||
className={
|
||||
user.status === 'disabled'
|
||||
? 'lx-role-editor__user-avatar is-disabled'
|
||||
: 'lx-role-editor__user-avatar'
|
||||
}
|
||||
>
|
||||
{user.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="lx-role-editor__user-name">{user.name}</span>
|
||||
<span className="lx-role-editor__user-account">{user.account}</span>
|
||||
<span className="lx-role-editor__user-dept">{user.departmentName}</span>
|
||||
<span
|
||||
className={
|
||||
user.status === 'active'
|
||||
? 'lx-status-tag is-active'
|
||||
: 'lx-status-tag is-disabled'
|
||||
}
|
||||
>
|
||||
{user.status === 'active' ? '在职' : '停用'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="lx-role-editor__block">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>生效与审计</h3>
|
||||
</header>
|
||||
<p className="lx-role-editor__note">
|
||||
<IconAlert size={14} />
|
||||
权限与数据范围变更在保存后写入审计日志(role.updated),绑定用户下一次请求即生效。
|
||||
</p>
|
||||
<p className="lx-role-editor__meta-line">
|
||||
当前状态 <RoleStatusTag status={detail.status} /> · 最近更新{' '}
|
||||
{formatRoleDateTime(detail.updatedAt)}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeState({
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
}): ReactNode {
|
||||
if (loading) {
|
||||
return <p className="lx-role-editor__muted">权限点目录加载中…</p>;
|
||||
}
|
||||
if (error !== null) {
|
||||
return (
|
||||
<p className="lx-role-editor__muted">
|
||||
权限点目录加载失败。
|
||||
<button type="button" className="lx-role-editor__link" onClick={onRetry}>
|
||||
重新加载
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <p className="lx-role-editor__muted">权限点目录暂不可用</p>;
|
||||
}
|
||||
|
||||
function PermissionsTab({
|
||||
tree,
|
||||
treeLoading,
|
||||
treeError,
|
||||
onRetryTree,
|
||||
editor,
|
||||
locked,
|
||||
readonly,
|
||||
}: Pick<
|
||||
RoleEditorTabsProps,
|
||||
'tree' | 'treeLoading' | 'treeError' | 'onRetryTree' | 'editor' | 'locked' | 'readonly'
|
||||
>): ReactNode {
|
||||
if (tree === undefined) {
|
||||
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
|
||||
}
|
||||
return (
|
||||
<div className="lx-role-editor__perm">
|
||||
<RolePermissionTree
|
||||
tree={tree}
|
||||
selected={new Set(editor.draft.permissions)}
|
||||
special={new Set(editor.draft.specialPermissions)}
|
||||
conflicts={editor.conflictCodes}
|
||||
readonly={readonly || locked}
|
||||
onTogglePermission={editor.togglePermission}
|
||||
onToggleModule={editor.toggleModule}
|
||||
/>
|
||||
{locked ? (
|
||||
<p className="lx-role-editor__note">
|
||||
<IconLock size={14} />
|
||||
系统内置角色的权限由系统保证,不支持在此调整。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeTab({
|
||||
editor,
|
||||
locked,
|
||||
readonly,
|
||||
}: Pick<RoleEditorTabsProps, 'editor' | 'locked' | 'readonly'>): ReactNode {
|
||||
return (
|
||||
<div className="lx-role-editor__scope">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>数据范围</h3>
|
||||
<span>决定该角色可见的客户与数据边界,与权限点同时生效取交集</span>
|
||||
</header>
|
||||
<RoleScopePicker
|
||||
value={{
|
||||
type: editor.draft.dataScope.type,
|
||||
targetIds: editor.draft.dataScope.targetIds,
|
||||
}}
|
||||
readonly={readonly || locked}
|
||||
onChange={editor.setScopeType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialTab({
|
||||
tree,
|
||||
treeLoading,
|
||||
treeError,
|
||||
onRetryTree,
|
||||
editor,
|
||||
detail,
|
||||
locked,
|
||||
readonly,
|
||||
onSpecialRequest,
|
||||
}: Pick<
|
||||
RoleEditorTabsProps,
|
||||
| 'tree'
|
||||
| 'treeLoading'
|
||||
| 'treeError'
|
||||
| 'onRetryTree'
|
||||
| 'editor'
|
||||
| 'detail'
|
||||
| 'locked'
|
||||
| 'readonly'
|
||||
| 'onSpecialRequest'
|
||||
>): ReactNode {
|
||||
if (tree === undefined) {
|
||||
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
|
||||
}
|
||||
return (
|
||||
<div className="lx-role-editor__special">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>特殊权限</h3>
|
||||
<span>默认关闭、单独授予、不随角色复制带入</span>
|
||||
</header>
|
||||
<RoleSpecialPermissions
|
||||
tree={tree}
|
||||
granted={new Set(editor.draft.specialPermissions)}
|
||||
readonly={readonly || locked}
|
||||
holderCount={detail.userCount}
|
||||
onRequest={onSpecialRequest}
|
||||
/>
|
||||
{locked ? (
|
||||
<p className="lx-role-editor__note">
|
||||
<IconLock size={14} />
|
||||
系统内置角色的特殊权限由系统保证,不支持在此调整。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tabLabel(label: string, badge?: string, danger = false): ReactNode {
|
||||
return (
|
||||
<span className="lx-role-editor__tab-label">
|
||||
{label}
|
||||
{badge === undefined ? null : (
|
||||
<span
|
||||
className={danger ? 'lx-role-editor__tab-badge is-danger' : 'lx-role-editor__tab-badge'}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 编辑器头部 Tabs:基本信息 / 权限点 / 数据范围 / 特殊权限 */
|
||||
export function RoleEditorTabs(props: RoleEditorTabsProps): ReactNode {
|
||||
const totalPermissions =
|
||||
props.tree?.totalPermissions ??
|
||||
props.detail.permissionCount + props.detail.specialPermissionCount;
|
||||
const selectedCount =
|
||||
props.editor.draft.permissions.length + props.editor.draft.specialPermissions.length;
|
||||
return (
|
||||
<Tabs
|
||||
className="lx-role-editor__tabs"
|
||||
type="line"
|
||||
activeKey={props.activeTab}
|
||||
onChange={(key) => {
|
||||
props.onChangeTab(key as RoleEditorTabKey);
|
||||
}}
|
||||
>
|
||||
<Tabs.TabPane key="basic" itemKey="basic" tab={tabLabel('基本信息')}>
|
||||
{props.activeTab === 'basic' ? (
|
||||
<BasicTab
|
||||
detail={props.detail}
|
||||
editor={props.editor}
|
||||
readonly={props.readonly}
|
||||
locked={props.locked}
|
||||
errors={props.errors}
|
||||
/>
|
||||
) : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
key="permissions"
|
||||
itemKey="permissions"
|
||||
tab={tabLabel(
|
||||
'权限点',
|
||||
`${String(selectedCount)} / ${String(totalPermissions)}`,
|
||||
props.editor.conflicts.length > 0,
|
||||
)}
|
||||
>
|
||||
{props.activeTab === 'permissions' ? (
|
||||
<PermissionsTab
|
||||
tree={props.tree}
|
||||
treeLoading={props.treeLoading}
|
||||
treeError={props.treeError}
|
||||
onRetryTree={props.onRetryTree}
|
||||
editor={props.editor}
|
||||
locked={props.locked}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
) : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="scope" itemKey="scope" tab={tabLabel('数据范围')}>
|
||||
{props.activeTab === 'scope' ? (
|
||||
<ScopeTab editor={props.editor} locked={props.locked} readonly={props.readonly} />
|
||||
) : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
key="special"
|
||||
itemKey="special"
|
||||
tab={tabLabel(
|
||||
'特殊权限',
|
||||
`${String(props.editor.draft.specialPermissions.length)} / ${String(
|
||||
props.tree?.specialPermissions.length ?? 6,
|
||||
)}`,
|
||||
)}
|
||||
>
|
||||
{props.activeTab === 'special' ? (
|
||||
<SpecialTab
|
||||
tree={props.tree}
|
||||
treeLoading={props.treeLoading}
|
||||
treeError={props.treeError}
|
||||
onRetryTree={props.onRetryTree}
|
||||
editor={props.editor}
|
||||
detail={props.detail}
|
||||
locked={props.locked}
|
||||
readonly={props.readonly}
|
||||
onSpecialRequest={props.onSpecialRequest}
|
||||
/>
|
||||
) : null}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/* eslint-disable max-lines-per-function -- 列表卡包含筛选态、行菜单与骨架,集中在一处便于对齐状态 */
|
||||
import { Button, Dropdown, Skeleton } from '@douyinfe/semi-ui-19';
|
||||
import type { RoleListItem } from '@lingxi/shared';
|
||||
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDelete,
|
||||
IconEdit,
|
||||
IconMore,
|
||||
IconPause,
|
||||
IconPlay,
|
||||
IconShield,
|
||||
IconView,
|
||||
} from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { scopeLabelOf } from './role-display';
|
||||
import { RoleStatusTag } from './RoleStatusTag';
|
||||
|
||||
export type RoleRowAction = 'view' | 'rename' | 'edit' | 'copy' | 'disable' | 'enable' | 'delete';
|
||||
|
||||
export interface RoleListPanelProps {
|
||||
roles: readonly RoleListItem[];
|
||||
/** 未筛选的角色总数,用于区分「没有匹配」与「还没有自定义角色」 */
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
selectedRoleId: string | null;
|
||||
/** 卡头右侧说明:内置 / 自定义计数或「当前:角色名」 */
|
||||
note: string;
|
||||
footerNote?: string;
|
||||
canManage: boolean;
|
||||
showRowMenu: boolean;
|
||||
isFiltered: boolean;
|
||||
onSelect: (roleId: string) => void;
|
||||
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
|
||||
onCreate: () => void;
|
||||
onResetFilters: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function rolePermissionText(role: RoleListItem): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<b>{role.permissionCount}</b> 普通
|
||||
{role.specialPermissionCount > 0 ? (
|
||||
<span className="lx-role-row__special"> · ★{role.specialPermissionCount}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
role,
|
||||
canManage,
|
||||
onRowAction,
|
||||
}: {
|
||||
role: RoleListItem;
|
||||
canManage: boolean;
|
||||
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
|
||||
}): ReactNode {
|
||||
const [visible, setVisible] = useState(false);
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
const trigger = (action: RoleRowAction): void => {
|
||||
setVisible(false);
|
||||
onRowAction(action, role);
|
||||
};
|
||||
const menuItems: ReactNode[] = role.isSystem
|
||||
? [
|
||||
<Dropdown.Item
|
||||
key="view"
|
||||
icon={<IconView size={14} />}
|
||||
onClick={() => {
|
||||
trigger('view');
|
||||
}}
|
||||
>
|
||||
查看角色
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item
|
||||
key="rename"
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
trigger('rename');
|
||||
}}
|
||||
>
|
||||
改名
|
||||
</Dropdown.Item>,
|
||||
]
|
||||
: [
|
||||
<Dropdown.Item
|
||||
key="edit"
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
trigger('edit');
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item
|
||||
key="copy"
|
||||
icon={<IconCopy size={14} />}
|
||||
onClick={() => {
|
||||
trigger('copy');
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item
|
||||
key="toggle"
|
||||
icon={role.status === 'active' ? <IconPause size={14} /> : <IconPlay size={14} />}
|
||||
onClick={() => {
|
||||
trigger(role.status === 'active' ? 'disable' : 'enable');
|
||||
}}
|
||||
>
|
||||
{role.status === 'active' ? '停用' : '启用'}
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Divider key="divider" />,
|
||||
<Dropdown.Item
|
||||
key="delete"
|
||||
type="danger"
|
||||
icon={<IconDelete size={14} />}
|
||||
disabled={role.userCount > 0}
|
||||
onClick={() => {
|
||||
trigger('delete');
|
||||
}}
|
||||
>
|
||||
{role.userCount > 0 ? '删除(有绑定用户)' : '删除'}
|
||||
</Dropdown.Item>,
|
||||
];
|
||||
return (
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
visible={visible}
|
||||
onVisibleChange={setVisible}
|
||||
render={<Dropdown.Menu>{menuItems}</Dropdown.Menu>}
|
||||
>
|
||||
<Button
|
||||
className="lx-role-row__more"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
icon={<IconMore size={16} />}
|
||||
aria-label={`${role.name} 更多操作`}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function ListBody({
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
selectedRoleId,
|
||||
canManage,
|
||||
showRowMenu,
|
||||
isFiltered,
|
||||
onSelect,
|
||||
onRowAction,
|
||||
onCreate,
|
||||
onResetFilters,
|
||||
onRetry,
|
||||
}: Pick<
|
||||
RoleListPanelProps,
|
||||
| 'roles'
|
||||
| 'loading'
|
||||
| 'error'
|
||||
| 'selectedRoleId'
|
||||
| 'canManage'
|
||||
| 'showRowMenu'
|
||||
| 'isFiltered'
|
||||
| 'onSelect'
|
||||
| 'onRowAction'
|
||||
| 'onCreate'
|
||||
| 'onResetFilters'
|
||||
| 'onRetry'
|
||||
>): ReactNode {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="lx-role-list__state" aria-busy="true">
|
||||
<Skeleton placeholder={<Skeleton.Paragraph rows={9} />} loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error !== null) {
|
||||
return (
|
||||
<ErrorState
|
||||
className="lx-role-list__state"
|
||||
title="角色列表加载失败"
|
||||
message={error.message}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (roles.length === 0) {
|
||||
if (isFiltered) {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="没有匹配的角色"
|
||||
description="换个角色名称、编码或说明再试,也可以清除当前筛选。"
|
||||
action={<Button onClick={onResetFilters}>清除筛选</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PageEmpty
|
||||
title="还没有自定义角色"
|
||||
description="内置角色由系统维护不可删除;可按岗位新建自定义角色并分配权限。"
|
||||
action={
|
||||
<Button theme="solid" type="primary" onClick={onCreate}>
|
||||
新建角色
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="lx-role-list__items" role="listbox" aria-label="角色">
|
||||
{roles.map((role) => {
|
||||
const selected = role.id === selectedRoleId;
|
||||
return (
|
||||
<li key={role.id}>
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={0}
|
||||
className={[
|
||||
'lx-role-row',
|
||||
selected ? 'is-selected' : '',
|
||||
role.status === 'disabled' ? 'is-disabled' : '',
|
||||
]
|
||||
.filter((item) => item !== '')
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
onSelect(role.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(role.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="lx-role-row__icon" aria-hidden="true">
|
||||
<IconShield size={16} />
|
||||
</span>
|
||||
<span className="lx-role-row__main">
|
||||
<span className="lx-role-row__name">
|
||||
<RoleStatusTag status={role.status} dotOnly />
|
||||
<b>{role.name}</b>
|
||||
{role.isSystem ? (
|
||||
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
|
||||
) : null}
|
||||
{role.status === 'disabled' ? <span className="lx-role-tag">停用</span> : null}
|
||||
</span>
|
||||
<span className="lx-role-row__meta">
|
||||
{role.code}
|
||||
<span className="lx-role-row__dot">·</span>
|
||||
{scopeLabelOf(role.dataScope.type)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lx-role-row__side">
|
||||
<span className="lx-role-row__perm">{rolePermissionText(role)}</span>
|
||||
<span className="lx-role-row__users">{role.userCount} 人绑定</span>
|
||||
</span>
|
||||
{showRowMenu ? (
|
||||
<span
|
||||
className="lx-role-row__menu"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<RowMenu role={role} canManage={canManage} onRowAction={onRowAction} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 左侧角色列表卡:卡头说明 + 列表 / 三态 + 页脚统计 */
|
||||
export function RoleListPanel(props: RoleListPanelProps): ReactNode {
|
||||
return (
|
||||
<section className="lx-role-list" aria-label="角色列表">
|
||||
<header className="lx-role-list__head">
|
||||
<span className="lx-role-list__title">角色</span>
|
||||
<span className="lx-role-list__note">{props.note}</span>
|
||||
</header>
|
||||
<div className="lx-role-list__body">
|
||||
<ListBody {...props} />
|
||||
</div>
|
||||
<footer className="lx-role-list__foot">
|
||||
<span className="lx-role-list__range">共 {props.roles.length} 个角色</span>
|
||||
<span className="lx-role-list__foot-note">
|
||||
{props.footerNote ??
|
||||
`启用 ${String(props.roles.filter((role) => role.status === 'active').length)} · 停用 ${String(props.roles.filter((role) => role.status === 'disabled').length)}`}
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 新建页负责模板预填、表单草稿与创建编排,集中便于对照原型 */
|
||||
import { Button, Input, TextArea } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
isPermissionCode,
|
||||
type PermissionCode,
|
||||
type PermissionModuleNode,
|
||||
type PermissionNode,
|
||||
type RoleDetail,
|
||||
type RoleListItem,
|
||||
} from '@lingxi/shared';
|
||||
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconAdd, IconBack, IconCopy, IconDocs, IconShield } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import {
|
||||
useCreateRoleMutation,
|
||||
usePermissionTreeQuery,
|
||||
useRoleDetailQuery,
|
||||
useRoleListQuery,
|
||||
} from '@/hooks/queries/use-roles';
|
||||
import { roleDetailPath, ROUTES } from '@/routes';
|
||||
|
||||
import { SpecialPermissionDialog } from './role-dialogs';
|
||||
import { scopeLabelOf } from './role-display';
|
||||
import {
|
||||
createEmptyDraft,
|
||||
draftFromDetail,
|
||||
findDraftSodConflicts,
|
||||
type RoleDraft,
|
||||
type RoleDraftErrors,
|
||||
validateCreateDraft,
|
||||
} from './role-utils';
|
||||
import { RolePermissionTree } from './RolePermissionTree';
|
||||
import { RoleScopePicker } from './RoleScopePicker';
|
||||
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
|
||||
|
||||
const TEMPLATE_ROLE_QUERY = {
|
||||
limit: 100,
|
||||
status: 'active',
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
} as const;
|
||||
|
||||
const BLANK_TEMPLATE = '__blank__';
|
||||
|
||||
interface TemplateListProps {
|
||||
templates: readonly RoleListItem[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
selectedKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function TemplateList({
|
||||
templates,
|
||||
loading,
|
||||
error,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onRetry,
|
||||
}: TemplateListProps): ReactNode {
|
||||
return (
|
||||
<section className="lx-role-templates" aria-label="角色模板">
|
||||
<header className="lx-role-templates__head">
|
||||
<span className="lx-role-templates__title">角色模板</span>
|
||||
<span className="lx-role-templates__note">
|
||||
{templates.length === 0 ? '仅空白' : `${String(templates.length + 1)} 个`}
|
||||
</span>
|
||||
</header>
|
||||
<div className="lx-role-templates__body">
|
||||
{loading ? (
|
||||
<div className="lx-role-templates__state">
|
||||
<PageSkeleton rows={8} />
|
||||
</div>
|
||||
) : error !== null ? (
|
||||
<ErrorState
|
||||
className="lx-role-templates__state"
|
||||
title="角色模板加载失败"
|
||||
message={error.message}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
) : (
|
||||
<div role="listbox" aria-label="选择模板">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedKey === BLANK_TEMPLATE}
|
||||
className={
|
||||
selectedKey === BLANK_TEMPLATE ? 'lx-role-template is-active' : 'lx-role-template'
|
||||
}
|
||||
onClick={() => {
|
||||
onSelect(BLANK_TEMPLATE);
|
||||
}}
|
||||
>
|
||||
<span className="lx-role-template__icon" aria-hidden="true">
|
||||
<IconDocs size={16} />
|
||||
</span>
|
||||
<span className="lx-role-template__main">
|
||||
<span className="lx-role-template__name">从空白开始</span>
|
||||
<span className="lx-role-template__desc">不预置任何权限,逐项配置</span>
|
||||
<span className="lx-role-template__meta">
|
||||
<span>权限 0</span>
|
||||
<i>·</i>
|
||||
<span>未设置范围</span>
|
||||
<i>·</i>
|
||||
<span>特殊 0</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{templates.map((role) => {
|
||||
const active = selectedKey === role.id;
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={active ? 'lx-role-template is-active' : 'lx-role-template'}
|
||||
onClick={() => {
|
||||
onSelect(role.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-role-template__icon" aria-hidden="true">
|
||||
<IconShield size={16} />
|
||||
</span>
|
||||
<span className="lx-role-template__main">
|
||||
<span className="lx-role-template__name">
|
||||
{role.name}
|
||||
{role.isSystem ? (
|
||||
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="lx-role-template__desc">{role.description}</span>
|
||||
<span className="lx-role-template__meta">
|
||||
<span>权限 {role.permissionCount}</span>
|
||||
<i>·</i>
|
||||
<span>{scopeLabelOf(role.dataScope.type)}</span>
|
||||
<i>·</i>
|
||||
<span className={role.specialPermissionCount > 0 ? 'has-special' : ''}>
|
||||
{role.specialPermissionCount > 0
|
||||
? `★特殊 ${String(role.specialPermissionCount)}`
|
||||
: '特殊 0'}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="lx-role-templates__foot">
|
||||
模板只带入普通权限与数据范围,★ 特殊权限需创建后单独授予
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NewFormHeader({
|
||||
templateRole,
|
||||
fromCopy,
|
||||
templateLoading,
|
||||
}: {
|
||||
templateRole: RoleDetail | undefined;
|
||||
fromCopy: boolean;
|
||||
templateLoading: boolean;
|
||||
}): ReactNode {
|
||||
const title =
|
||||
templateRole === undefined
|
||||
? '从空白开始创建'
|
||||
: fromCopy
|
||||
? `复制「${templateRole.name}」`
|
||||
: `从「${templateRole.name}」模板创建`;
|
||||
const sub =
|
||||
templateRole === undefined
|
||||
? '未预置任何权限与数据范围,按需逐项配置'
|
||||
: fromCopy
|
||||
? '已带入权限与数据范围,特殊权限需创建后单独授予'
|
||||
: templateRole.description;
|
||||
return (
|
||||
<header className="lx-role-new__head">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{templateLoading ? '正在读取来源角色…' : sub}</p>
|
||||
</div>
|
||||
<span className="lx-role-tag lx-role-tag--brand">
|
||||
{templateRole === undefined ? '空白配置' : fromCopy ? '复制角色' : '已套用模板'}
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ message }: { message: string | undefined }): ReactNode {
|
||||
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
|
||||
}
|
||||
|
||||
/** 新建 / 复制角色页(/system/roles/new?from=):左模板列表 + 右表单 */
|
||||
export function RoleNewPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const fromRoleId = searchParams.get('from');
|
||||
const listQuery = useRoleListQuery(TEMPLATE_ROLE_QUERY);
|
||||
const tree = usePermissionTreeQuery();
|
||||
const createRole = useCreateRoleMutation();
|
||||
const templates = useMemo(
|
||||
() => (listQuery.data?.items ?? []).filter((role) => !role.isSystem),
|
||||
[listQuery.data],
|
||||
);
|
||||
const [selectedKey, setSelectedKey] = useState<string>(fromRoleId ?? BLANK_TEMPLATE);
|
||||
const [draft, setDraft] = useState<RoleDraft>(createEmptyDraft);
|
||||
const [errors, setErrors] = useState<RoleDraftErrors>({});
|
||||
const [specialDialog, setSpecialDialog] = useState<{
|
||||
permission: PermissionNode;
|
||||
granted: boolean;
|
||||
} | null>(null);
|
||||
const [prefilledId, setPrefilledId] = useState<string | null>(null);
|
||||
const templateRoleId = selectedKey === BLANK_TEMPLATE ? null : selectedKey;
|
||||
const templateDetail = useRoleDetailQuery(templateRoleId);
|
||||
const fromCopy = fromRoleId !== null && fromRoleId === selectedKey;
|
||||
const conflicts = findDraftSodConflicts(draft);
|
||||
const conflictCodes = useMemo(() => {
|
||||
const codes = new Set<string>();
|
||||
for (const conflict of conflicts) {
|
||||
codes.add(conflict.permissions[0]);
|
||||
codes.add(conflict.permissions[1]);
|
||||
}
|
||||
return codes;
|
||||
}, [conflicts]);
|
||||
|
||||
// 模板数据到达后一次性预填(渲染期对齐状态,避免 effect 级联渲染)
|
||||
const templateRole = templateDetail.data;
|
||||
if (templateRoleId === null) {
|
||||
if (prefilledId !== null) {
|
||||
setPrefilledId(null);
|
||||
setDraft(createEmptyDraft());
|
||||
setErrors({});
|
||||
}
|
||||
} else if (templateRole !== undefined && prefilledId !== templateRole.id) {
|
||||
setPrefilledId(templateRole.id);
|
||||
setDraft(draftFromDetail(templateRole, { includeSpecial: false, nameSuffix: ' 副本' }));
|
||||
setErrors({});
|
||||
}
|
||||
|
||||
const togglePermission = (code: string): void => {
|
||||
if (!isPermissionCode(code)) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => {
|
||||
const has = current.permissions.includes(code);
|
||||
return {
|
||||
...current,
|
||||
permissions: has
|
||||
? current.permissions.filter((item) => item !== code)
|
||||
: [...current.permissions, code],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleModule = (module: PermissionModuleNode, selectAll: boolean): void => {
|
||||
const normalCodes: PermissionCode[] = module.permissions
|
||||
.filter((permission) => !permission.isSpecial && isPermissionCode(permission.code))
|
||||
.map((permission) => permission.code)
|
||||
.filter(isPermissionCode);
|
||||
setDraft((current) => {
|
||||
const next = new Set(current.permissions);
|
||||
for (const code of normalCodes) {
|
||||
if (selectAll) {
|
||||
next.add(code);
|
||||
} else {
|
||||
next.delete(code);
|
||||
}
|
||||
}
|
||||
return { ...current, permissions: [...next] };
|
||||
});
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
const validation = validateCreateDraft(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.input === null) {
|
||||
notifyWarning(validation.errors.name ?? '请检查角色信息');
|
||||
return;
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
notifyWarning(`存在职责互斥:${conflicts[0]?.label ?? ''},请调整权限点`);
|
||||
return;
|
||||
}
|
||||
createRole.mutate(validation.input, {
|
||||
onSuccess: (result) => {
|
||||
notifySuccess('角色已创建,正在进入编辑器');
|
||||
void navigate(roleDetailPath(result.role.id), { replace: true });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lx-roles">
|
||||
<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">
|
||||
<h1>新建角色</h1>
|
||||
<p>从模板开始或从空白配置,创建后可继续调整权限与数据范围</p>
|
||||
</div>
|
||||
<div className="lx-roles__header-actions">
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.system.roles);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
loading={createRole.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
创建角色
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="lx-roles__split">
|
||||
<TemplateList
|
||||
templates={templates}
|
||||
loading={listQuery.isPending}
|
||||
error={listQuery.error}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={(key) => {
|
||||
setSelectedKey(key);
|
||||
setPrefilledId(null);
|
||||
}}
|
||||
onRetry={() => {
|
||||
void listQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="lx-role-new" aria-label="新建角色表单">
|
||||
<NewFormHeader
|
||||
templateRole={templateDetail.data}
|
||||
fromCopy={fromCopy}
|
||||
templateLoading={templateDetail.isPending && templateRoleId !== null}
|
||||
/>
|
||||
<div className="lx-role-new__body">
|
||||
{fromCopy ? (
|
||||
<div className="lx-role-new__alert is-info">
|
||||
<IconCopy size={15} />
|
||||
<span>
|
||||
已复制「{templateDetail.data?.name ?? ''}」的权限与数据范围,特殊权限未带入。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{conflicts.length > 0 ? (
|
||||
<div className="lx-role-new__alert is-danger">
|
||||
<span>
|
||||
命中职责互斥:{conflicts.map((conflict) => conflict.label).join(';')}
|
||||
,请调整冲突权限点后重试。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="lx-role-editor__block">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>基本信息</h3>
|
||||
<span>名称与说明用于列表展示和成员分配</span>
|
||||
</header>
|
||||
<div className="lx-role-editor__grid">
|
||||
<label className="lx-role-field">
|
||||
<span className="lx-role-field__label">
|
||||
角色名称
|
||||
<i className="lx-role-field__required">*</i>
|
||||
<span className="lx-role-field__hint">{draft.name.length}/64</span>
|
||||
</span>
|
||||
<Input
|
||||
value={draft.name}
|
||||
maxLength={64}
|
||||
placeholder="例如:审核员"
|
||||
onChange={(value) => {
|
||||
setDraft((current) => ({ ...current, name: value }));
|
||||
setErrors({});
|
||||
}}
|
||||
/>
|
||||
<FieldError message={errors.name} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="lx-role-field">
|
||||
<span className="lx-role-field__label">
|
||||
角色说明
|
||||
<span className="lx-role-field__hint">{draft.description.length}/255</span>
|
||||
</span>
|
||||
<TextArea
|
||||
value={draft.description}
|
||||
maxLength={255}
|
||||
autosize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="一句话说明该角色的职责"
|
||||
onChange={(value) => {
|
||||
setDraft((current) => ({ ...current, description: value }));
|
||||
}}
|
||||
/>
|
||||
<FieldError message={errors.description} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="lx-role-editor__block">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>权限点</h3>
|
||||
<span>模板已带入的权限点可继续增减</span>
|
||||
</header>
|
||||
{tree.data === undefined ? (
|
||||
<p className="lx-role-editor__muted">
|
||||
{tree.isPending
|
||||
? '权限点目录加载中…'
|
||||
: '权限点目录加载失败,创建后可在编辑器中继续配置。'}
|
||||
</p>
|
||||
) : (
|
||||
<RolePermissionTree
|
||||
tree={tree.data}
|
||||
selected={new Set(draft.permissions)}
|
||||
special={new Set(draft.specialPermissions)}
|
||||
conflicts={conflictCodes}
|
||||
readonly={false}
|
||||
onTogglePermission={togglePermission}
|
||||
onToggleModule={toggleModule}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="lx-role-editor__block">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>数据范围</h3>
|
||||
<span>与权限点同时生效,取交集</span>
|
||||
</header>
|
||||
<RoleScopePicker
|
||||
value={{ type: draft.dataScope.type, targetIds: draft.dataScope.targetIds }}
|
||||
readonly={false}
|
||||
onChange={(type) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
dataScope: {
|
||||
type,
|
||||
targetIds: type === 'customer_groups' ? current.dataScope.targetIds : [],
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="lx-role-editor__block">
|
||||
<header className="lx-role-editor__block-head">
|
||||
<h3>特殊权限</h3>
|
||||
<span>默认全关,创建后也可在编辑器中授予</span>
|
||||
</header>
|
||||
{tree.data === undefined ? (
|
||||
<p className="lx-role-editor__muted">特殊权限目录加载中…</p>
|
||||
) : (
|
||||
<RoleSpecialPermissions
|
||||
tree={tree.data}
|
||||
granted={new Set(draft.specialPermissions)}
|
||||
readonly={false}
|
||||
holderCount={0}
|
||||
onRequest={(code, granted) => {
|
||||
const permission = tree.data.specialPermissions.find(
|
||||
(item) => item.code === code,
|
||||
);
|
||||
if (permission !== undefined) {
|
||||
setSpecialDialog({ permission, granted });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<footer className="lx-role-new__foot">
|
||||
<span className="lx-role-new__foot-note">
|
||||
已选 {draft.permissions.length + draft.specialPermissions.length} /{' '}
|
||||
{tree.data?.totalPermissions ?? 30} 权限点 · {scopeLabelOf(draft.dataScope.type)} ·
|
||||
特殊权限 {draft.specialPermissions.length}
|
||||
</span>
|
||||
<span className="lx-role-new__foot-hint">创建后进入编辑器继续配置</span>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SpecialPermissionDialog
|
||||
visible={specialDialog !== null}
|
||||
permission={specialDialog?.permission ?? null}
|
||||
granted={specialDialog?.granted ?? false}
|
||||
holderCount={0}
|
||||
onCancel={() => {
|
||||
setSpecialDialog(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (specialDialog === null) {
|
||||
return;
|
||||
}
|
||||
const code = specialDialog.permission.code;
|
||||
if (!isPermissionCode(code)) {
|
||||
setSpecialDialog(null);
|
||||
return;
|
||||
}
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
specialPermissions: specialDialog.granted
|
||||
? [...current.specialPermissions, code]
|
||||
: current.specialPermissions.filter((item) => item !== code),
|
||||
}));
|
||||
setSpecialDialog(null);
|
||||
notifySuccess(
|
||||
specialDialog.granted
|
||||
? `已选择「${specialDialog.permission.name}」,创建后生效`
|
||||
: `已取消「${specialDialog.permission.name}」`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { PermissionTree } from '@lingxi/shared';
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
permissionsByModule,
|
||||
permissionTreeSchema,
|
||||
SOD_PAIRS,
|
||||
} from '@lingxi/shared';
|
||||
import { notifyWarning } from '@lingxi/ui';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RolePermissionTree } from './RolePermissionTree';
|
||||
|
||||
vi.mock('@lingxi/ui', () => ({
|
||||
notifyWarning: vi.fn(),
|
||||
}));
|
||||
|
||||
// jsdom 无 canvas,Semi 整包会加载 Lottie;测试只渲染树,替换所需基础组件
|
||||
vi.mock('@douyinfe/semi-ui-19', () => ({
|
||||
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Input: ({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) => (
|
||||
<input
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
onChange?.(event.target.value);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockedNotifyWarning = vi.mocked(notifyWarning);
|
||||
|
||||
function buildTree(): PermissionTree {
|
||||
const roleNode = (permission: (typeof PERMISSION_CATALOG)[number]) => ({
|
||||
code: permission.code,
|
||||
name: permission.name,
|
||||
description: permission.description,
|
||||
isSpecial: permission.isSpecial,
|
||||
});
|
||||
return permissionTreeSchema.parse({
|
||||
modules: permissionsByModule().map((module) => ({
|
||||
module: module.module,
|
||||
moduleLabel: module.moduleLabel,
|
||||
sortOrder: module.sortOrder,
|
||||
permissions: module.permissions.map(roleNode),
|
||||
})),
|
||||
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(
|
||||
roleNode,
|
||||
),
|
||||
sodPairs: SOD_PAIRS.map((pair) => ({
|
||||
id: pair.id,
|
||||
label: pair.label,
|
||||
description: pair.description,
|
||||
permissions: [pair.permissions[0], pair.permissions[1]],
|
||||
})),
|
||||
totalPermissions: PERMISSION_CATALOG.length,
|
||||
});
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
selected?: string[];
|
||||
special?: string[];
|
||||
conflicts?: string[];
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function renderTree(options: RenderOptions = {}): {
|
||||
onTogglePermission: ReturnType<typeof vi.fn>;
|
||||
onToggleModule: ReturnType<typeof vi.fn>;
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const onTogglePermission = vi.fn();
|
||||
const onToggleModule = vi.fn();
|
||||
const { container } = render(
|
||||
<RolePermissionTree
|
||||
tree={buildTree()}
|
||||
selected={new Set(options.selected ?? [])}
|
||||
special={new Set(options.special ?? [])}
|
||||
conflicts={new Set(options.conflicts ?? [])}
|
||||
readonly={options.readonly ?? false}
|
||||
onTogglePermission={onTogglePermission}
|
||||
onToggleModule={onToggleModule}
|
||||
/>,
|
||||
);
|
||||
return { onTogglePermission, onToggleModule, container };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockedNotifyWarning.mockReset();
|
||||
});
|
||||
|
||||
describe('权限点树', () => {
|
||||
it('按目录渲染 10 个模块与已选计数', () => {
|
||||
const { container } = renderTree({ selected: ['script:read'] });
|
||||
const tools = container.querySelector('.lx-role-tree__tools');
|
||||
expect(tools?.textContent).toContain('已选');
|
||||
expect(tools?.textContent).toContain('30');
|
||||
expect(screen.getByText('脚本')).toBeTruthy();
|
||||
expect(screen.getByText('素材')).toBeTruthy();
|
||||
expect(screen.getByText('组织与角色')).toBeTruthy();
|
||||
expect(screen.getByText('审计')).toBeTruthy();
|
||||
expect(container.querySelector('.lx-role-module__count')?.textContent).toContain('1 / 3');
|
||||
});
|
||||
|
||||
it('默认展开已选模块,点击模块标题展开其余模块', () => {
|
||||
renderTree({ selected: ['script:read'] });
|
||||
expect(screen.getByText('查看脚本')).toBeTruthy();
|
||||
expect(screen.queryByText('查看素材任务')).toBeNull();
|
||||
fireEvent.click(screen.getByText('素材'));
|
||||
expect(screen.getByText('查看素材任务')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('点击普通权限点行触发切换', () => {
|
||||
const { onTogglePermission } = renderTree({ selected: ['script:read'] });
|
||||
fireEvent.click(screen.getByText('查看脚本'));
|
||||
expect(onTogglePermission).toHaveBeenCalledWith('script:read');
|
||||
});
|
||||
|
||||
it('点击模块三态复选框触发整模块选中', () => {
|
||||
const { container, onToggleModule } = renderTree({ selected: ['script:read'] });
|
||||
const module = container.querySelector('[data-module="script"]');
|
||||
expect(module).not.toBeNull();
|
||||
const check = module?.querySelector('.lx-role-module__check');
|
||||
expect(check?.getAttribute('aria-checked')).toBe('mixed');
|
||||
if (check !== null && check !== undefined) {
|
||||
fireEvent.click(check);
|
||||
}
|
||||
expect(onToggleModule).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleModule.mock.calls[0]?.[1]).toBe(true);
|
||||
});
|
||||
|
||||
it('特殊权限行不切换,只提示需单独授予', () => {
|
||||
const { container, onTogglePermission } = renderTree({});
|
||||
fireEvent.click(screen.getByText('导出交付'));
|
||||
const specialRow = container.querySelector('[data-code="delivery:export"]');
|
||||
expect(specialRow).not.toBeNull();
|
||||
if (specialRow !== null) {
|
||||
fireEvent.click(specialRow);
|
||||
}
|
||||
expect(onTogglePermission).not.toHaveBeenCalledWith('delivery:export');
|
||||
expect(mockedNotifyWarning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SoD 冲突行高亮', () => {
|
||||
const { container } = renderTree({
|
||||
selected: ['script:write', 'script:review'],
|
||||
conflicts: ['script:write', 'script:review'],
|
||||
});
|
||||
expect(container.querySelector('[data-code="script:write"]')?.className).toContain(
|
||||
'is-conflict',
|
||||
);
|
||||
expect(container.querySelector('[data-code="script:read"]')?.className).not.toContain(
|
||||
'is-conflict',
|
||||
);
|
||||
});
|
||||
|
||||
it('只读态禁用交互', () => {
|
||||
const { onTogglePermission } = renderTree({ selected: ['script:read'], readonly: true });
|
||||
fireEvent.click(screen.getByText('查看脚本'));
|
||||
expect(onTogglePermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('搜索权限点后只保留匹配模块并自动展开', () => {
|
||||
renderTree({});
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索权限点名称或编码'), {
|
||||
target: { value: '发起导出' },
|
||||
});
|
||||
expect(screen.queryByText('脚本')).toBeNull();
|
||||
expect(screen.getByText('导出交付')).toBeTruthy();
|
||||
expect(screen.getByText('发起导出')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
/* eslint-disable max-lines-per-function -- 权限树包含工具条、模块三态与行渲染,集中在一处便于对齐目录 */
|
||||
import { Button, Input } from '@douyinfe/semi-ui-19';
|
||||
import type { PermissionModuleNode, PermissionTree } from '@lingxi/shared';
|
||||
import { notifyWarning } from '@lingxi/ui';
|
||||
import { IconChevronDown, IconChevronRight, IconSearch } from '@lingxi/ui/icons';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
export interface RolePermissionTreeProps {
|
||||
tree: PermissionTree;
|
||||
/** 普通权限点(特殊权限不得混入) */
|
||||
selected: ReadonlySet<string>;
|
||||
/** 特殊权限点(树内只读展示,授予在「特殊权限」区单独确认) */
|
||||
special: ReadonlySet<string>;
|
||||
/** SoD 冲突权限点编码,树内高亮 */
|
||||
conflicts: ReadonlySet<string>;
|
||||
readonly: boolean;
|
||||
onTogglePermission: (code: string) => void;
|
||||
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
|
||||
}
|
||||
|
||||
function moduleSelectedCount(
|
||||
module: PermissionModuleNode,
|
||||
selected: ReadonlySet<string>,
|
||||
special: ReadonlySet<string>,
|
||||
): number {
|
||||
return module.permissions.filter((permission) =>
|
||||
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code),
|
||||
).length;
|
||||
}
|
||||
|
||||
function normalPermissionsOf(module: PermissionModuleNode): string[] {
|
||||
return module.permissions
|
||||
.filter((permission) => !permission.isSpecial)
|
||||
.map((permission) => permission.code);
|
||||
}
|
||||
|
||||
function initialExpandedModules(
|
||||
tree: PermissionTree,
|
||||
selected: ReadonlySet<string>,
|
||||
special: ReadonlySet<string>,
|
||||
): ReadonlySet<string> {
|
||||
const expanded = new Set<string>();
|
||||
for (const module of tree.modules) {
|
||||
if (moduleSelectedCount(module, selected, special) > 0) {
|
||||
expanded.add(module.module);
|
||||
}
|
||||
}
|
||||
if (expanded.size === 0) {
|
||||
for (const module of tree.modules.slice(0, 2)) {
|
||||
expanded.add(module.module);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function matchesKeyword(
|
||||
module: PermissionModuleNode,
|
||||
keyword: string,
|
||||
): PermissionModuleNode | null {
|
||||
if (keyword === '') {
|
||||
return module;
|
||||
}
|
||||
const permissions = module.permissions.filter((permission) =>
|
||||
`${permission.name} ${permission.code} ${permission.description}`
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
return permissions.length === 0 ? null : { ...module, permissions };
|
||||
}
|
||||
|
||||
function CheckStateIcon({ state }: { state: 'on' | 'off' | 'half' }): ReactNode {
|
||||
return <span className={`lx-role-check is-${state}`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function PermissionRow({
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
isSpecial,
|
||||
checked,
|
||||
conflict,
|
||||
readonly,
|
||||
onToggle,
|
||||
onSpecialHint,
|
||||
}: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isSpecial: boolean;
|
||||
checked: boolean;
|
||||
conflict: boolean;
|
||||
readonly: boolean;
|
||||
onToggle: () => void;
|
||||
onSpecialHint: () => void;
|
||||
}): ReactNode {
|
||||
const interactive = !readonly && !isSpecial;
|
||||
const activate = (): void => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
if (isSpecial) {
|
||||
onSpecialHint();
|
||||
return;
|
||||
}
|
||||
onToggle();
|
||||
};
|
||||
const className = [
|
||||
'lx-role-perm-row',
|
||||
checked ? 'is-checked' : '',
|
||||
isSpecial ? 'is-special' : '',
|
||||
conflict ? 'is-conflict' : '',
|
||||
interactive ? 'is-interactive' : '',
|
||||
]
|
||||
.filter((item) => item !== '')
|
||||
.join(' ');
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-disabled={!interactive}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
data-code={code}
|
||||
onClick={activate}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="lx-role-perm-row__check">
|
||||
<CheckStateIcon state={checked ? 'on' : 'off'} />
|
||||
</span>
|
||||
<span className="lx-role-perm-row__main">
|
||||
<span className="lx-role-perm-row__name">
|
||||
{name}
|
||||
{isSpecial ? <span className="lx-role-tag lx-role-tag--special">★ 特殊</span> : null}
|
||||
</span>
|
||||
<span className="lx-role-perm-row__desc">{description}</span>
|
||||
{isSpecial ? (
|
||||
<span className="lx-role-perm-row__hint">
|
||||
特殊权限需在「特殊权限」中单独授予并二次确认
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<code className="lx-role-perm-row__code">{code}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleBlock({
|
||||
module,
|
||||
selected,
|
||||
special,
|
||||
conflicts,
|
||||
readonly,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
onTogglePermission,
|
||||
onToggleModule,
|
||||
}: {
|
||||
module: PermissionModuleNode;
|
||||
selected: ReadonlySet<string>;
|
||||
special: ReadonlySet<string>;
|
||||
conflicts: ReadonlySet<string>;
|
||||
readonly: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onTogglePermission: (code: string) => void;
|
||||
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
|
||||
}): ReactNode {
|
||||
const normalCodes = normalPermissionsOf(module);
|
||||
const selectedNormal = normalCodes.filter((code) => selected.has(code)).length;
|
||||
const state: 'on' | 'off' | 'half' =
|
||||
selectedNormal === 0 ? 'off' : selectedNormal === normalCodes.length ? 'on' : 'half';
|
||||
const specialCount = module.permissions.filter((permission) => permission.isSpecial).length;
|
||||
const totalSelected = moduleSelectedCount(module, selected, special);
|
||||
return (
|
||||
<div
|
||||
className={expanded ? 'lx-role-module is-open' : 'lx-role-module'}
|
||||
data-module={module.module}
|
||||
>
|
||||
<div className="lx-role-module__head">
|
||||
<span
|
||||
className={[
|
||||
'lx-role-module__check',
|
||||
readonly ? 'is-readonly' : '',
|
||||
normalCodes.length === 0 ? 'is-locked' : '',
|
||||
]
|
||||
.filter((item) => item !== '')
|
||||
.join(' ')}
|
||||
role="checkbox"
|
||||
aria-checked={state === 'on' ? true : state === 'half' ? 'mixed' : false}
|
||||
aria-disabled={readonly || normalCodes.length === 0}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
title={
|
||||
normalCodes.length === 0
|
||||
? '该模块权限点均为特殊权限,请在「特殊权限」中授予'
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (readonly || normalCodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
onToggleModule(module, state !== 'on');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (readonly || normalCodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
onToggleModule(module, state !== 'on');
|
||||
}}
|
||||
>
|
||||
<CheckStateIcon state={state} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-role-module__toggle"
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<span className="lx-role-module__name">{module.moduleLabel}</span>
|
||||
{specialCount > 0 ? (
|
||||
<span className="lx-role-module__star" title="该模块含特殊权限">
|
||||
★ {specialCount}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={
|
||||
totalSelected === 0 ? 'lx-role-module__count' : 'lx-role-module__count is-active'
|
||||
}
|
||||
>
|
||||
{totalSelected} / {module.permissions.length}
|
||||
</span>
|
||||
<span className="lx-role-module__caret">
|
||||
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="lx-role-module__body">
|
||||
{module.permissions.map((permission) => (
|
||||
<PermissionRow
|
||||
key={permission.code}
|
||||
code={permission.code}
|
||||
name={permission.name}
|
||||
description={permission.description}
|
||||
isSpecial={permission.isSpecial}
|
||||
checked={
|
||||
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code)
|
||||
}
|
||||
conflict={conflicts.has(permission.code)}
|
||||
readonly={readonly}
|
||||
onToggle={() => {
|
||||
onTogglePermission(permission.code);
|
||||
}}
|
||||
onSpecialHint={() => {
|
||||
notifyWarning('特殊权限需在「特殊权限」中单独授予并二次确认');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 权限点树:10 模块 / 30 点,模块三态、搜索、折叠;特殊权限树内只读 */
|
||||
export function RolePermissionTree({
|
||||
tree,
|
||||
selected,
|
||||
special,
|
||||
conflicts,
|
||||
readonly,
|
||||
onTogglePermission,
|
||||
onToggleModule,
|
||||
}: RolePermissionTreeProps): ReactNode {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() =>
|
||||
initialExpandedModules(tree, selected, special),
|
||||
);
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
const visibleModules = useMemo(() => {
|
||||
const matched: PermissionModuleNode[] = [];
|
||||
for (const module of tree.modules) {
|
||||
const result = matchesKeyword(module, normalizedKeyword);
|
||||
if (result !== null) {
|
||||
matched.push(result);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}, [tree, normalizedKeyword]);
|
||||
|
||||
const isExpanded = (module: string): boolean => normalizedKeyword !== '' || expanded.has(module);
|
||||
|
||||
const setAllExpanded = (open: boolean): void => {
|
||||
setExpanded(open ? new Set(tree.modules.map((module) => module.module)) : new Set());
|
||||
};
|
||||
|
||||
const toggleExpanded = (module: string): void => {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(module)) {
|
||||
next.delete(module);
|
||||
} else {
|
||||
next.add(module);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lx-role-tree">
|
||||
<div className="lx-role-tree__tools">
|
||||
<Input
|
||||
className="lx-role-tree__search"
|
||||
value={keyword}
|
||||
prefix={<IconSearch size={15} />}
|
||||
placeholder="搜索权限点名称或编码"
|
||||
aria-label="搜索权限点"
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
setKeyword(value);
|
||||
}}
|
||||
/>
|
||||
<span className="lx-role-tree__selected">
|
||||
已选 <b>{selected.size + special.size}</b> / {tree.totalPermissions}
|
||||
</span>
|
||||
<span className="lx-role-tree__links">
|
||||
<Button
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setAllExpanded(true);
|
||||
}}
|
||||
>
|
||||
展开全部
|
||||
</Button>
|
||||
<Button
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setAllExpanded(false);
|
||||
}}
|
||||
>
|
||||
收起全部
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{visibleModules.length === 0 ? (
|
||||
<p className="lx-role-tree__empty">没有匹配的权限点,换个名称或编码再试。</p>
|
||||
) : (
|
||||
<div className="lx-role-tree__modules">
|
||||
{visibleModules.map((module) => (
|
||||
<ModuleBlock
|
||||
key={module.module}
|
||||
module={module}
|
||||
selected={selected}
|
||||
special={special}
|
||||
conflicts={conflicts}
|
||||
readonly={readonly}
|
||||
expanded={isExpanded(module.module)}
|
||||
onToggleExpanded={() => {
|
||||
toggleExpanded(module.module);
|
||||
}}
|
||||
onTogglePermission={onTogglePermission}
|
||||
onToggleModule={onToggleModule}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
|
||||
import type { DataScopeType } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ROLE_SCOPE_OPTIONS, scopeOptionOf } from './role-display';
|
||||
|
||||
export interface RoleScopeValue {
|
||||
type: DataScopeType;
|
||||
targetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface RoleScopePickerProps {
|
||||
value: RoleScopeValue;
|
||||
readonly: boolean;
|
||||
onChange: (type: DataScopeType) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 六类数据范围单选卡片。
|
||||
* A3 前客户组目录为空:选中「指定客户组」时展示空态,不伪造客户组数据。
|
||||
*/
|
||||
export function RoleScopePicker({ value, readonly, onChange }: RoleScopePickerProps): ReactNode {
|
||||
const selected = scopeOptionOf(value.type);
|
||||
return (
|
||||
<div className="lx-role-scope">
|
||||
<div className="lx-role-scope__grid" role="radiogroup" aria-label="数据范围">
|
||||
{ROLE_SCOPE_OPTIONS.map((option) => {
|
||||
const active = option.type === value.type;
|
||||
return (
|
||||
<button
|
||||
key={option.type}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
disabled={readonly}
|
||||
className={active ? 'lx-role-scope__option is-active' : 'lx-role-scope__option'}
|
||||
onClick={() => {
|
||||
if (!readonly && !active) {
|
||||
onChange(option.type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="lx-role-scope__radio" aria-hidden="true" />
|
||||
<span className="lx-role-scope__copy">
|
||||
<span className="lx-role-scope__name">{option.label}</span>
|
||||
<span className="lx-role-scope__desc">{option.description}</span>
|
||||
</span>
|
||||
{option.needsTargets ? <span className="lx-role-scope__need">需选客户组</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{value.type === 'customer_groups' ? (
|
||||
<div className="lx-role-scope__groups">
|
||||
<div className="lx-role-scope__groups-head">
|
||||
<b>客户组</b>
|
||||
<span>
|
||||
{value.targetIds.length === 0
|
||||
? '选择客户组后按组批量授权'
|
||||
: `已指定 ${String(value.targetIds.length)} 个客户组`}
|
||||
</span>
|
||||
</div>
|
||||
{value.targetIds.length === 0 ? (
|
||||
<div className="lx-role-scope__empty">
|
||||
<b>暂无可选客户组</b>
|
||||
<span>
|
||||
客户组将在客户模块上线后可选择;当前角色先按「指定客户组」保存,后续再补充授权范围。
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="lx-role-scope__note">
|
||||
已指定 {value.targetIds.length} 个客户组。客户组名称与覆盖客户将在客户模块上线后展示。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="lx-role-scope__foot">
|
||||
<span className="lx-role-scope__foot-mark" aria-hidden="true" />
|
||||
当前范围:{selected.label}
|
||||
。数据范围随用户所属部门或负责客户动态计算,变更后立即对绑定用户生效。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
|
||||
import { Switch } from '@douyinfe/semi-ui-19';
|
||||
import type { PermissionTree } from '@lingxi/shared';
|
||||
import { IconAlert, IconStar } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { specialPermissionRiskOf } from './role-display';
|
||||
|
||||
export interface RoleSpecialPermissionsProps {
|
||||
tree: PermissionTree;
|
||||
granted: ReadonlySet<string>;
|
||||
readonly: boolean;
|
||||
/** 当前角色绑定用户数(授予 / 撤销的影响面) */
|
||||
holderCount: number;
|
||||
onRequest: (code: string, granted: boolean) => void;
|
||||
}
|
||||
|
||||
/** 特殊权限区:6 个独立开关,授予 / 撤销由父级弹二次确认,不随复制带入 */
|
||||
export function RoleSpecialPermissions({
|
||||
tree,
|
||||
granted,
|
||||
readonly,
|
||||
holderCount,
|
||||
onRequest,
|
||||
}: RoleSpecialPermissionsProps): ReactNode {
|
||||
return (
|
||||
<div className="lx-role-special">
|
||||
<div className="lx-role-special__banner">
|
||||
<IconAlert size={16} />
|
||||
<span>特殊权限默认关闭,不随角色复制带入;授予后记录审计,并立即对绑定用户生效。</span>
|
||||
</div>
|
||||
<div className="lx-role-special__list">
|
||||
{tree.specialPermissions.map((permission) => {
|
||||
const active = granted.has(permission.code);
|
||||
return (
|
||||
<div
|
||||
key={permission.code}
|
||||
className={active ? 'lx-role-special__row is-granted' : 'lx-role-special__row'}
|
||||
>
|
||||
<div className="lx-role-special__main">
|
||||
<div className="lx-role-special__name">
|
||||
{permission.name}
|
||||
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
|
||||
</div>
|
||||
<code className="lx-role-special__code">{permission.code}</code>
|
||||
<p className="lx-role-special__desc">{permission.description}</p>
|
||||
<p className="lx-role-special__risk">
|
||||
<IconAlert size={13} />
|
||||
{specialPermissionRiskOf(permission.code)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-role-special__side">
|
||||
<Switch
|
||||
checked={active}
|
||||
disabled={readonly}
|
||||
aria-label={permission.name}
|
||||
onChange={(checked) => {
|
||||
if (!readonly && checked !== active) {
|
||||
onRequest(permission.code, checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="lx-role-special__holder">
|
||||
{active ? <IconStar size={12} /> : null}
|
||||
持有者 <b>{active ? holderCount : 0}</b> 名
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="lx-role-special__note">
|
||||
特殊权限与普通权限分开授予:普通权限决定能做什么,特殊权限决定能否让数据离开平台或影响全部客户。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { RoleStatus } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { roleStatusLabel } from './role-display';
|
||||
|
||||
export interface RoleStatusTagProps {
|
||||
status: RoleStatus;
|
||||
/** 列表行内只显示状态点,详情展示完整标签 */
|
||||
dotOnly?: boolean;
|
||||
}
|
||||
|
||||
export function RoleStatusTag({ status, dotOnly = false }: RoleStatusTagProps): ReactNode {
|
||||
const active = status === 'active';
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? dotOnly
|
||||
? 'lx-status-dot'
|
||||
: 'lx-status-tag is-active'
|
||||
: dotOnly
|
||||
? 'lx-status-dot is-disabled'
|
||||
: 'lx-status-tag is-disabled'
|
||||
}
|
||||
title={roleStatusLabel(status)}
|
||||
>
|
||||
<span className="lx-status-tag__dot" aria-hidden="true" />
|
||||
{dotOnly ? null : roleStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 角色二次确认弹窗模板固定,集中在一处便于统一文案口径 */
|
||||
import { Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||
import type { PermissionNode, RoleDetail, RoleListItem } from '@lingxi/shared';
|
||||
import { IconAlert, IconShield, IconStar } from '@lingxi/ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { RolePermissionMeta } from './role-display';
|
||||
import {
|
||||
permissionNameOf,
|
||||
roleStatusLabel,
|
||||
scopeLabelOf,
|
||||
specialPermissionRiskOf,
|
||||
} from './role-display';
|
||||
import type { RoleDraftDiff } from './role-utils';
|
||||
|
||||
/** 停用 / 删除 / 改名弹窗所需的最小角色字段(列表项与详情均满足) */
|
||||
export type RoleDialogTarget = Pick<
|
||||
RoleListItem,
|
||||
'id' | 'name' | 'code' | 'status' | 'userCount' | 'dataScope' | 'isSystem'
|
||||
>;
|
||||
|
||||
export interface RoleObjectCardProps {
|
||||
name: string;
|
||||
meta: string;
|
||||
status?: string;
|
||||
icon?: ComponentType<{ size?: number | string }>;
|
||||
}
|
||||
|
||||
/** 弹窗内的角色对象卡 */
|
||||
export function RoleObjectCard({
|
||||
name,
|
||||
meta,
|
||||
status,
|
||||
icon: Icon = IconShield,
|
||||
}: RoleObjectCardProps): ReactNode {
|
||||
return (
|
||||
<div className="lx-role-dialog__object">
|
||||
<span className="lx-role-dialog__object-icon" aria-hidden="true">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="lx-role-dialog__object-copy">
|
||||
<span className="lx-role-dialog__object-title">{name}</span>
|
||||
<span className="lx-role-dialog__object-meta">{meta}</span>
|
||||
</span>
|
||||
{status === undefined ? null : <span className="lx-role-tag">{status}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogWarn({
|
||||
children,
|
||||
tone = 'warning',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: 'warning' | 'danger' | 'info';
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={`lx-role-dialog__warn is-${tone}`}>
|
||||
<IconAlert size={15} />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 弹窗内容由父级通过 key 控制重挂载:打开时重置初始值,关闭时卸载,避免 effect 同步状态。
|
||||
|
||||
export interface DisableRoleDialogProps {
|
||||
visible: boolean;
|
||||
role: RoleDialogTarget | null;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
}
|
||||
|
||||
/** 停用角色:影响用户数 + 勾选知晓;停用可随时恢复 */
|
||||
export function DisableRoleDialog({
|
||||
visible,
|
||||
role,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DisableRoleDialogProps): ReactNode {
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="停用角色"
|
||||
className="lx-role-modal"
|
||||
width={520}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!loading}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<span className="lx-role-dialog__foot-note">
|
||||
停用可随时恢复,不会删除角色及其权限配置
|
||||
</span>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="danger"
|
||||
disabled={!acknowledged || loading}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onConfirm(reason.trim());
|
||||
}}
|
||||
>
|
||||
停用角色
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-dialog">
|
||||
{role === null ? null : (
|
||||
<RoleObjectCard
|
||||
name={role.name}
|
||||
meta={`${role.code} · ${scopeLabelOf(role.dataScope.type)} · ${String(role.userCount)} 名用户`}
|
||||
status={roleStatusLabel(role.status)}
|
||||
/>
|
||||
)}
|
||||
<DialogWarn>
|
||||
停用后,{role?.userCount ?? 0}{' '}
|
||||
名绑定用户将在下一次请求即失去该角色权限;停用不会删除角色配置。
|
||||
</DialogWarn>
|
||||
<label className="lx-role-dialog__field">
|
||||
<span>停用原因(选填,写入审计)</span>
|
||||
<Input
|
||||
value={reason}
|
||||
maxLength={200}
|
||||
placeholder="例如:岗位调整、角色合并"
|
||||
onChange={setReason}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onChange={(event) => {
|
||||
setAcknowledged(event.target.checked === true);
|
||||
}}
|
||||
>
|
||||
我已知晓停用后这些用户下一次请求即失去该角色权限
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DeleteRoleDialogProps {
|
||||
visible: boolean;
|
||||
role: RoleDialogTarget | null;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/** 删除角色:仅 0 绑定用户可用,删除不可恢复 */
|
||||
export function DeleteRoleDialog({
|
||||
visible,
|
||||
role,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteRoleDialogProps): ReactNode {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="删除角色"
|
||||
className="lx-role-modal"
|
||||
width={520}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!loading}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<span className="lx-role-dialog__foot-note">删除后不可恢复,审计日志保留授权记录</span>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="danger"
|
||||
onClick={onConfirm}
|
||||
disabled={loading || (role?.userCount ?? 0) > 0}
|
||||
loading={loading}
|
||||
>
|
||||
删除角色
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-dialog">
|
||||
{role === null ? null : (
|
||||
<RoleObjectCard
|
||||
name={role.name}
|
||||
meta={`${role.code} · ${String(role.userCount)} 名用户`}
|
||||
status="自定义"
|
||||
/>
|
||||
)}
|
||||
<DialogWarn tone="danger">
|
||||
删除后不可恢复,该角色的权限配置会一并删除,授权记录会保留在审计日志中。
|
||||
</DialogWarn>
|
||||
<p className="lx-role-dialog__hint">
|
||||
{role !== null && role.userCount > 0
|
||||
? '该角色仍有绑定用户,无法删除;请先将用户调整到其他角色,或改为停用。'
|
||||
: '该角色当前没有绑定用户,可从模板重新创建。'}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RenameRoleDialogProps {
|
||||
visible: boolean;
|
||||
role: RoleDialogTarget | null;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (name: string) => void;
|
||||
}
|
||||
|
||||
/** 系统角色改名:仅显示名可改 */
|
||||
export function RenameRoleDialog({
|
||||
visible,
|
||||
role,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RenameRoleDialogProps): ReactNode {
|
||||
const [name, setName] = useState(() => role?.name ?? '');
|
||||
const trimmed = name.trim();
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="修改角色名称"
|
||||
className="lx-role-modal"
|
||||
width={520}
|
||||
onCancel={onCancel}
|
||||
closeOnEsc={!loading}
|
||||
footer={
|
||||
<>
|
||||
<span className="lx-role-dialog__foot-note">改名会记录审计</span>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
disabled={trimmed === '' || loading}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onConfirm(trimmed);
|
||||
}}
|
||||
>
|
||||
保存名称
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-dialog">
|
||||
<label className="lx-role-dialog__field">
|
||||
<span>角色名称</span>
|
||||
<Input
|
||||
value={name}
|
||||
maxLength={64}
|
||||
onChange={setName}
|
||||
onEnterPress={() => {
|
||||
if (trimmed !== '' && !loading) {
|
||||
onConfirm(trimmed);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<DialogWarn tone="info">
|
||||
超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。
|
||||
</DialogWarn>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CopyRoleDialogProps {
|
||||
visible: boolean;
|
||||
source: RoleDetail | null;
|
||||
sourceLoading: boolean;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (name: string) => void;
|
||||
}
|
||||
|
||||
/** 复制角色:带入普通权限与数据范围,特殊权限不随复制带入 */
|
||||
export function CopyRoleDialog({
|
||||
visible,
|
||||
source,
|
||||
sourceLoading,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: CopyRoleDialogProps): ReactNode {
|
||||
const [name, setName] = useState(() => (source === null ? '' : `${source.name} 副本`));
|
||||
const trimmed = name.trim();
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="复制角色"
|
||||
className="lx-role-modal"
|
||||
width={520}
|
||||
onCancel={onCancel}
|
||||
closeOnEsc={!loading}
|
||||
footer={
|
||||
<>
|
||||
<span className="lx-role-dialog__foot-note">新角色创建后状态为启用</span>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
disabled={trimmed === '' || loading || sourceLoading || source === null}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onConfirm(trimmed);
|
||||
}}
|
||||
>
|
||||
创建副本
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-dialog">
|
||||
{source === null ? (
|
||||
<p className="lx-role-dialog__hint">
|
||||
{sourceLoading ? '正在读取来源角色…' : '来源角色不可用'}
|
||||
</p>
|
||||
) : (
|
||||
<RoleObjectCard
|
||||
name={source.name}
|
||||
meta={`${source.code} · ${scopeLabelOf(source.dataScope.type)} · ${String(source.permissionCount)} 个权限点`}
|
||||
status={roleStatusLabel(source.status)}
|
||||
/>
|
||||
)}
|
||||
<label className="lx-role-dialog__field">
|
||||
<span>新角色名称</span>
|
||||
<Input value={name} maxLength={64} placeholder="请输入新角色名称" onChange={setName} />
|
||||
</label>
|
||||
<DialogWarn tone="warning">
|
||||
将带入 {source?.permissionCount ?? 0} 个普通权限点与「
|
||||
{source === null ? '—' : scopeLabelOf(source.dataScope.type)}
|
||||
」数据范围;特殊权限不会随复制带入, 需创建后单独授予。
|
||||
</DialogWarn>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SpecialPermissionDialogProps {
|
||||
visible: boolean;
|
||||
permission: PermissionNode | null;
|
||||
granted: boolean;
|
||||
holderCount: number;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/** 特殊权限授予 / 撤销二次确认(保存前不落库,确认后更新草稿) */
|
||||
export function SpecialPermissionDialog({
|
||||
visible,
|
||||
permission,
|
||||
granted,
|
||||
holderCount,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: SpecialPermissionDialogProps): ReactNode {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={granted ? '授予特殊权限' : '撤销特殊权限'}
|
||||
className="lx-role-modal"
|
||||
width={520}
|
||||
maskClosable={false}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<span className="lx-role-dialog__foot-note">
|
||||
{granted ? '授予后立即生效' : '撤销后立即生效'}
|
||||
</span>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button theme="solid" type={granted ? 'primary' : 'danger'} onClick={onConfirm}>
|
||||
{granted ? '确认授予' : '确认撤销'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-dialog">
|
||||
<DialogWarn tone={granted ? 'warning' : 'danger'}>
|
||||
{granted
|
||||
? `将为 ${String(holderCount)} 名用户授予「${permission?.name ?? ''}」特殊权限。该权限不随角色复制带入,授予后记录审计。`
|
||||
: `撤销后 ${String(holderCount)} 名用户将立即失去「${permission?.name ?? ''}」权限,撤销会记录审计。`}
|
||||
</DialogWarn>
|
||||
{permission === null ? null : (
|
||||
<RoleObjectCard
|
||||
name={permission.name}
|
||||
meta={permission.code}
|
||||
status="特殊权限"
|
||||
icon={IconStar}
|
||||
/>
|
||||
)}
|
||||
<p className="lx-role-dialog__hint">
|
||||
{permission === null
|
||||
? ''
|
||||
: `${permission.description}。${specialPermissionRiskOf(permission.code)}。`}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SaveRoleDialogProps {
|
||||
visible: boolean;
|
||||
role: RoleDetail | null;
|
||||
diff: RoleDraftDiff | null;
|
||||
meta: ReadonlyMap<string, RolePermissionMeta> | null;
|
||||
baseName: string;
|
||||
draftName: string;
|
||||
baseScope: string;
|
||||
draftScope: string;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function ChangeRow({ kind, text }: { kind: string; text: string }): ReactNode {
|
||||
return (
|
||||
<div className="lx-role-dialog__change">
|
||||
<span className="lx-role-dialog__change-kind">{kind}</span>
|
||||
<span className="lx-role-dialog__change-text">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存前影响面确认:权限 diff + 基本信息 + 数据范围 + 影响用户数 */
|
||||
export function SaveRoleDialog({
|
||||
visible,
|
||||
role,
|
||||
diff,
|
||||
meta,
|
||||
baseName,
|
||||
draftName,
|
||||
baseScope,
|
||||
draftScope,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: SaveRoleDialogProps): ReactNode {
|
||||
const names = (codes: readonly string[]): string =>
|
||||
codes.map((code) => (meta === null ? code : permissionNameOf(meta, code))).join('、');
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="保存角色变更"
|
||||
className="lx-role-modal"
|
||||
width={560}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!loading}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<span className="lx-role-dialog__foot-note">可在审计日志中按角色检索本次变更</span>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
继续编辑
|
||||
</Button>
|
||||
<Button theme="solid" type="primary" onClick={onConfirm} loading={loading}>
|
||||
保存并生效
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-dialog">
|
||||
{role === null ? null : (
|
||||
<RoleObjectCard
|
||||
name={role.name}
|
||||
meta={`${role.code} · ${String(role.userCount)} 名用户`}
|
||||
status={roleStatusLabel(role.status)}
|
||||
/>
|
||||
)}
|
||||
<div className="lx-role-dialog__changes">
|
||||
<div className="lx-role-dialog__changes-head">
|
||||
变更摘要
|
||||
<span>{diff?.totalChanges ?? 0} 项</span>
|
||||
</div>
|
||||
<div className="lx-role-dialog__change-list">
|
||||
{diff === null ? null : (
|
||||
<>
|
||||
{diff.addedPermissions.length + diff.addedSpecial.length > 0 ? (
|
||||
<ChangeRow
|
||||
kind="新增"
|
||||
text={names([...diff.addedPermissions, ...diff.addedSpecial])}
|
||||
/>
|
||||
) : null}
|
||||
{diff.removedPermissions.length + diff.removedSpecial.length > 0 ? (
|
||||
<ChangeRow
|
||||
kind="移除"
|
||||
text={names([...diff.removedPermissions, ...diff.removedSpecial])}
|
||||
/>
|
||||
) : null}
|
||||
{diff.nameChanged ? (
|
||||
<ChangeRow kind="名称" text={`${baseName} → ${draftName}`} />
|
||||
) : null}
|
||||
{diff.descriptionChanged ? <ChangeRow kind="说明" text="已更新角色说明" /> : null}
|
||||
{diff.scopeChanged ? (
|
||||
<ChangeRow kind="范围" text={`${baseScope} → ${draftScope}`} />
|
||||
) : null}
|
||||
{!diff.hasPermissionChanges &&
|
||||
!diff.hasSpecialChanges &&
|
||||
!diff.nameChanged &&
|
||||
!diff.descriptionChanged &&
|
||||
!diff.scopeChanged ? (
|
||||
<ChangeRow kind="—" text="本次没有实际变更" />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="lx-role-dialog__hint">
|
||||
保存后,{role?.userCount ?? 0} 名用户将在下一次请求生效,变更会记录审计(role.updated)。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LeaveRoleDialogProps {
|
||||
visible: boolean;
|
||||
roleName: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/** 未保存离开提示(路由拦截 / 返回列表 / 切换角色共用) */
|
||||
export function LeaveRoleDialog({
|
||||
visible,
|
||||
roleName,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: LeaveRoleDialogProps): ReactNode {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="放弃未保存的修改?"
|
||||
className="lx-role-modal"
|
||||
width={480}
|
||||
maskClosable={false}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<span className="lx-role-dialog__foot-note">「{roleName}」有未保存的修改</span>
|
||||
<Button onClick={onCancel}>继续编辑</Button>
|
||||
<Button theme="solid" type="danger" onClick={onConfirm}>
|
||||
放弃并离开
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-role-dialog">
|
||||
<DialogWarn tone="warning">
|
||||
当前角色的权限、数据范围与基本信息修改尚未保存,离开后这些修改将丢失。
|
||||
</DialogWarn>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PermissionTree, RoleListItem } from '@lingxi/shared';
|
||||
import { permissionTreeSchema, roleListItemSchema } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPermissionMetaMap,
|
||||
DEFAULT_ROLE_LIST_FILTERS,
|
||||
filterRoles,
|
||||
formatRoleDateTime,
|
||||
hasActiveRoleFilters,
|
||||
permissionNameOf,
|
||||
scopeLabelOf,
|
||||
summarizePermissionModules,
|
||||
summarizeRoles,
|
||||
} from './role-display';
|
||||
|
||||
let index = 0;
|
||||
|
||||
function buildRole(overrides: Record<string, unknown> = {}): RoleListItem {
|
||||
index += 1;
|
||||
return roleListItemSchema.parse({
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
code: `role_${String(index)}`,
|
||||
name: `角色 ${String(index)}`,
|
||||
description: '角色说明',
|
||||
isSystem: false,
|
||||
status: 'active',
|
||||
permissionCount: 4,
|
||||
specialPermissionCount: 0,
|
||||
userCount: 3,
|
||||
dataScope: { type: 'department' },
|
||||
createdAt: '2026-10-27T02:40:00.000Z',
|
||||
updatedAt: '2026-10-27T02:40:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const tree: PermissionTree = permissionTreeSchema.parse({
|
||||
modules: [
|
||||
{
|
||||
module: 'script',
|
||||
moduleLabel: '脚本',
|
||||
sortOrder: 10,
|
||||
permissions: [
|
||||
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
|
||||
{ code: 'script:write', name: '编辑与生成脚本', description: '编辑', isSpecial: false },
|
||||
{ code: 'script:review', name: '审核脚本', description: '审核', isSpecial: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
module: 'delivery',
|
||||
moduleLabel: '导出交付',
|
||||
sortOrder: 40,
|
||||
permissions: [
|
||||
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
|
||||
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
specialPermissions: [
|
||||
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||
],
|
||||
sodPairs: [
|
||||
{
|
||||
id: 'script-write-review',
|
||||
label: '脚本生成 × 脚本审核',
|
||||
description: '不得同时拥有',
|
||||
permissions: ['script:write', 'script:review'],
|
||||
},
|
||||
],
|
||||
totalPermissions: 5,
|
||||
});
|
||||
|
||||
describe('权限点摘要与元数据', () => {
|
||||
it('按 10 模块口径统计已选 / 总数(含特殊权限)', () => {
|
||||
const summary = summarizePermissionModules(tree, [
|
||||
'script:read',
|
||||
'script:review',
|
||||
'delivery:export',
|
||||
]);
|
||||
expect(summary).toEqual([
|
||||
{ module: 'script', moduleLabel: '脚本', selected: 2, total: 3 },
|
||||
{ module: 'delivery', moduleLabel: '导出交付', selected: 1, total: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('权限点编码映射到名称,缺失时回退编码', () => {
|
||||
const meta = buildPermissionMetaMap(tree);
|
||||
expect(permissionNameOf(meta, 'delivery:export')).toBe('发起导出');
|
||||
expect(meta.get('delivery:export')?.isSpecial).toBe(true);
|
||||
expect(permissionNameOf(meta, 'ghost:code')).toBe('ghost:code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数据范围与时间展示', () => {
|
||||
it('六类数据范围名称与未知类型兜底', () => {
|
||||
expect(scopeLabelOf('customer_groups')).toBe('指定客户组');
|
||||
expect(scopeLabelOf('department_and_children')).toBe('本部门及下级');
|
||||
expect(scopeLabelOf('created_by_me')).toBe('本人创建');
|
||||
});
|
||||
|
||||
it('ISO 时间转 YYYY-MM-DD HH:mm,空值与非法值显示占位符', () => {
|
||||
expect(formatRoleDateTime('2026-10-27T02:40:00.000Z')).toMatch(/^2026-10-27 \d{2}:40$/);
|
||||
expect(formatRoleDateTime(null)).toBe('—');
|
||||
expect(formatRoleDateTime('not-a-date')).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色统计与筛选', () => {
|
||||
it('汇总角色 / 启用 / 停用 / 绑定用户 / 特殊授予 / 内置数', () => {
|
||||
const stats = summarizeRoles([
|
||||
buildRole({ isSystem: true, userCount: 2, specialPermissionCount: 6 }),
|
||||
buildRole({ status: 'disabled', userCount: 0 }),
|
||||
buildRole({ userCount: 5, specialPermissionCount: 1 }),
|
||||
]);
|
||||
expect(stats).toEqual({
|
||||
total: 3,
|
||||
active: 2,
|
||||
disabled: 1,
|
||||
boundUsers: 7,
|
||||
specialGrants: 7,
|
||||
systemCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('按关键词 / 状态 / 只看特殊权限筛选', () => {
|
||||
const roles = [
|
||||
buildRole({ name: '审核员', code: 'reviewer', description: '脚本与素材审核' }),
|
||||
buildRole({ name: '设计师', code: 'designer', status: 'disabled' }),
|
||||
buildRole({ name: '行业配置', specialPermissionCount: 1 }),
|
||||
];
|
||||
expect(
|
||||
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, keyword: '审核' }).map(
|
||||
(role) => role.code,
|
||||
),
|
||||
).toEqual(['reviewer']);
|
||||
expect(
|
||||
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, status: 'disabled' }).map(
|
||||
(role) => role.name,
|
||||
),
|
||||
).toEqual(['设计师']);
|
||||
expect(
|
||||
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true }).map(
|
||||
(role) => role.name,
|
||||
),
|
||||
).toEqual(['行业配置']);
|
||||
});
|
||||
|
||||
it('识别是否存在生效筛选', () => {
|
||||
expect(hasActiveRoleFilters(DEFAULT_ROLE_LIST_FILTERS)).toBe(false);
|
||||
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, keyword: ' ' })).toBe(false);
|
||||
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { DataScopeType, PermissionTree, RoleListItem, RoleStatus } from '@lingxi/shared';
|
||||
|
||||
/** 六类数据范围(PRD §5.4.3、技术架构 §6.2)的展示口径 */
|
||||
export interface RoleScopeOption {
|
||||
type: DataScopeType;
|
||||
label: string;
|
||||
description: string;
|
||||
/** 是否需要在选中后指定客户组(A3 前客户组无数据) */
|
||||
needsTargets: boolean;
|
||||
}
|
||||
|
||||
export const ROLE_SCOPE_OPTIONS: readonly RoleScopeOption[] = [
|
||||
{
|
||||
type: 'all',
|
||||
label: '全部客户',
|
||||
description: '平台内全部客户',
|
||||
needsTargets: false,
|
||||
},
|
||||
{
|
||||
type: 'department_and_children',
|
||||
label: '本部门及下级',
|
||||
description: '用户所属部门及下级部门',
|
||||
needsTargets: false,
|
||||
},
|
||||
{
|
||||
type: 'department',
|
||||
label: '本部门',
|
||||
description: '仅用户所属部门',
|
||||
needsTargets: false,
|
||||
},
|
||||
{
|
||||
type: 'customer_groups',
|
||||
label: '指定客户组',
|
||||
description: '按客户组批量授权',
|
||||
needsTargets: true,
|
||||
},
|
||||
{
|
||||
type: 'owned_customers',
|
||||
label: '负责客户',
|
||||
description: '客户负责人为本人',
|
||||
needsTargets: false,
|
||||
},
|
||||
{
|
||||
type: 'created_by_me',
|
||||
label: '本人创建',
|
||||
description: '仅本人创建的数据',
|
||||
needsTargets: false,
|
||||
},
|
||||
];
|
||||
|
||||
const SCOPE_OPTION_BY_TYPE: ReadonlyMap<DataScopeType, RoleScopeOption> = new Map(
|
||||
ROLE_SCOPE_OPTIONS.map((option) => [option.type, option]),
|
||||
);
|
||||
|
||||
export function scopeOptionOf(type: DataScopeType): RoleScopeOption {
|
||||
const option = SCOPE_OPTION_BY_TYPE.get(type);
|
||||
// DataScopeType 是封闭枚举,映射表必然覆盖;兜底仅防御后端返回脏数据
|
||||
return (
|
||||
option ?? { type, label: '未知范围', description: '请重新选择数据范围', needsTargets: false }
|
||||
);
|
||||
}
|
||||
|
||||
export function scopeLabelOf(type: DataScopeType): string {
|
||||
return scopeOptionOf(type).label;
|
||||
}
|
||||
|
||||
export function scopeDescriptionOf(type: DataScopeType): string {
|
||||
return scopeOptionOf(type).description;
|
||||
}
|
||||
|
||||
export const ROLE_STATUS_LABEL: Record<RoleStatus, string> = {
|
||||
active: '启用',
|
||||
disabled: '停用',
|
||||
};
|
||||
|
||||
export function roleStatusLabel(status: RoleStatus): string {
|
||||
return ROLE_STATUS_LABEL[status];
|
||||
}
|
||||
|
||||
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/** ISO 时间 → `YYYY-MM-DD HH:mm`;空值显示占位符 */
|
||||
export function formatRoleDateTime(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return DATE_TIME_FORMATTER.format(date).replaceAll('/', '-');
|
||||
}
|
||||
|
||||
export interface RoleModuleSummary {
|
||||
module: string;
|
||||
moduleLabel: string;
|
||||
selected: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 权限点摘要:按目录模块统计已选 / 总数(含特殊权限,与列表计数口径一致) */
|
||||
export function summarizePermissionModules(
|
||||
tree: PermissionTree,
|
||||
selected: readonly string[],
|
||||
): RoleModuleSummary[] {
|
||||
const selectedSet = new Set(selected);
|
||||
return tree.modules.map((node) => ({
|
||||
module: node.module,
|
||||
moduleLabel: node.moduleLabel,
|
||||
selected: node.permissions.filter((permission) => selectedSet.has(permission.code)).length,
|
||||
total: node.permissions.length,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface RolePermissionMeta {
|
||||
code: string;
|
||||
name: string;
|
||||
moduleLabel: string;
|
||||
description: string;
|
||||
isSpecial: boolean;
|
||||
}
|
||||
|
||||
/** 权限点编码 → 目录元数据(权限树与 SoD 提示共用,避免各页面重复查找) */
|
||||
export function buildPermissionMetaMap(
|
||||
tree: PermissionTree,
|
||||
): ReadonlyMap<string, RolePermissionMeta> {
|
||||
const map = new Map<string, RolePermissionMeta>();
|
||||
for (const node of tree.modules) {
|
||||
for (const permission of node.permissions) {
|
||||
map.set(permission.code, {
|
||||
code: permission.code,
|
||||
name: permission.name,
|
||||
moduleLabel: node.moduleLabel,
|
||||
description: permission.description,
|
||||
isSpecial: permission.isSpecial,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const permission of tree.specialPermissions) {
|
||||
if (!map.has(permission.code)) {
|
||||
map.set(permission.code, {
|
||||
code: permission.code,
|
||||
name: permission.name,
|
||||
moduleLabel: '特殊权限',
|
||||
description: permission.description,
|
||||
isSpecial: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 权限点名称;目录缺失时回退编码,保证弹窗与摘要不出现空白 */
|
||||
export function permissionNameOf(
|
||||
meta: ReadonlyMap<string, RolePermissionMeta>,
|
||||
code: string,
|
||||
): string {
|
||||
return meta.get(code)?.name ?? code;
|
||||
}
|
||||
|
||||
/** 特殊权限风险说明(授予 / 撤销确认弹窗展示) */
|
||||
export const SPECIAL_PERMISSION_RISK: Record<string, string> = {
|
||||
'delivery:export': '持有者可批量导出客户数据,仅授予确需对外交付的人员',
|
||||
'customer_field:approve': '持有者可批准客户数据结构变更,需与申请权限分离',
|
||||
'industry:publish': '发布后影响全部客户,请与行业包编辑权限分离授予',
|
||||
'ai:configure': '持有者可修改模型路由与预算额度,需与业务操作分离',
|
||||
'audit:read': '审计日志包含全平台操作记录,仅授予审计与合规岗位',
|
||||
'audit:export': '导出后数据离开平台,每次导出均留痕',
|
||||
};
|
||||
|
||||
export function specialPermissionRiskOf(code: string): string {
|
||||
return SPECIAL_PERMISSION_RISK[code] ?? '特殊权限默认关闭,授予后立即生效并记录审计';
|
||||
}
|
||||
|
||||
export interface RoleStatistics {
|
||||
total: number;
|
||||
active: number;
|
||||
disabled: number;
|
||||
boundUsers: number;
|
||||
specialGrants: number;
|
||||
systemCount: number;
|
||||
}
|
||||
|
||||
export function summarizeRoles(roles: readonly RoleListItem[]): RoleStatistics {
|
||||
let active = 0;
|
||||
let boundUsers = 0;
|
||||
let specialGrants = 0;
|
||||
let systemCount = 0;
|
||||
for (const role of roles) {
|
||||
if (role.status === 'active') {
|
||||
active += 1;
|
||||
}
|
||||
boundUsers += role.userCount;
|
||||
specialGrants += role.specialPermissionCount;
|
||||
if (role.isSystem) {
|
||||
systemCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: roles.length,
|
||||
active,
|
||||
disabled: roles.length - active,
|
||||
boundUsers,
|
||||
specialGrants,
|
||||
systemCount,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoleListFilters {
|
||||
keyword: string;
|
||||
status: 'all' | 'active' | 'disabled';
|
||||
specialOnly: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROLE_LIST_FILTERS: RoleListFilters = {
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
specialOnly: false,
|
||||
};
|
||||
|
||||
/** 角色列表筛选(角色 ≤ 20,前端本地过滤,避免逐字请求) */
|
||||
export function filterRoles(
|
||||
roles: readonly RoleListItem[],
|
||||
filters: RoleListFilters,
|
||||
): RoleListItem[] {
|
||||
const keyword = filters.keyword.trim().toLowerCase();
|
||||
return roles.filter((role) => {
|
||||
if (filters.status !== 'all' && role.status !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
if (filters.specialOnly && role.specialPermissionCount === 0) {
|
||||
return false;
|
||||
}
|
||||
if (keyword === '') {
|
||||
return true;
|
||||
}
|
||||
const haystack = `${role.name} ${role.code} ${role.description}`.toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
export function hasActiveRoleFilters(filters: RoleListFilters): boolean {
|
||||
return filters.keyword.trim() !== '' || filters.status !== 'all' || filters.specialOnly;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { RoleDetail } from '@lingxi/shared';
|
||||
import { roleDetailSchema } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
conflictsFromErrorDetails,
|
||||
createEmptyDraft,
|
||||
diffRoleDraft,
|
||||
draftFromDetail,
|
||||
findDraftSodConflicts,
|
||||
type RoleDraft,
|
||||
saveResultMessage,
|
||||
validateCreateDraft,
|
||||
validateUpdateDraft,
|
||||
} from './role-utils';
|
||||
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
|
||||
function buildDetail(overrides: Record<string, unknown> = {}): RoleDetail {
|
||||
return roleDetailSchema.parse({
|
||||
id: ROLE_ID,
|
||||
code: 'account_owner',
|
||||
name: '客户负责人',
|
||||
description: '客户资料、脚本审核与交付验收',
|
||||
isSystem: false,
|
||||
status: 'active',
|
||||
permissionCount: 5,
|
||||
specialPermissionCount: 1,
|
||||
userCount: 5,
|
||||
dataScope: { type: 'customer_groups', targetIds: [GROUP_ID] },
|
||||
permissions: [
|
||||
'customer:read',
|
||||
'customer:manage',
|
||||
'customer:transfer',
|
||||
'delivery:read',
|
||||
'script:review',
|
||||
],
|
||||
specialPermissions: ['delivery:export'],
|
||||
boundUsers: [],
|
||||
createdAt: '2026-10-25T01:30:00.000Z',
|
||||
updatedAt: '2026-10-25T01:30:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function draftOf(overrides: Partial<RoleDraft> = {}): RoleDraft {
|
||||
return {
|
||||
name: '客户负责人',
|
||||
description: '客户资料、脚本审核与交付验收',
|
||||
permissions: [
|
||||
'customer:read',
|
||||
'customer:manage',
|
||||
'customer:transfer',
|
||||
'delivery:read',
|
||||
'script:review',
|
||||
],
|
||||
specialPermissions: ['delivery:export'],
|
||||
dataScope: { type: 'customer_groups', targetIds: [GROUP_ID] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('模板 / 复制预填', () => {
|
||||
it('默认不带入特殊权限,名称可加副本后缀', () => {
|
||||
const draft = draftFromDetail(buildDetail(), { nameSuffix: ' 副本' });
|
||||
expect(draft.name).toBe('客户负责人 副本');
|
||||
expect(draft.permissions).toHaveLength(5);
|
||||
expect(draft.specialPermissions).toEqual([]);
|
||||
expect(draft.dataScope).toEqual({ type: 'customer_groups', targetIds: [GROUP_ID] });
|
||||
});
|
||||
|
||||
it('编辑器初始化时保留特殊权限', () => {
|
||||
const draft = draftFromDetail(buildDetail(), { includeSpecial: true });
|
||||
expect(draft.specialPermissions).toEqual(['delivery:export']);
|
||||
});
|
||||
|
||||
it('空白草稿使用最小数据范围', () => {
|
||||
const draft = createEmptyDraft();
|
||||
expect(draft.permissions).toEqual([]);
|
||||
expect(draft.specialPermissions).toEqual([]);
|
||||
expect(draft.dataScope.type).toBe('created_by_me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('草稿 diff', () => {
|
||||
it('完全一致时不是脏状态', () => {
|
||||
const diff = diffRoleDraft(draftOf(), draftOf());
|
||||
expect(diff.isDirty).toBe(false);
|
||||
expect(diff.totalChanges).toBe(0);
|
||||
});
|
||||
|
||||
it('识别权限新增 / 移除、特殊权限与数据范围变化', () => {
|
||||
const base = draftOf();
|
||||
const next = draftOf({
|
||||
permissions: ['customer:read', 'material:read'],
|
||||
specialPermissions: ['delivery:export', 'audit:read'],
|
||||
dataScope: { type: 'department', targetIds: [] },
|
||||
description: '更新说明',
|
||||
});
|
||||
const diff = diffRoleDraft(base, next);
|
||||
expect(diff.addedPermissions).toEqual(['material:read']);
|
||||
expect(diff.removedPermissions).toEqual([
|
||||
'customer:manage',
|
||||
'customer:transfer',
|
||||
'delivery:read',
|
||||
'script:review',
|
||||
]);
|
||||
expect(diff.addedSpecial).toEqual(['audit:read']);
|
||||
expect(diff.removedSpecial).toEqual([]);
|
||||
expect(diff.scopeChanged).toBe(true);
|
||||
expect(diff.descriptionChanged).toBe(true);
|
||||
expect(diff.isDirty).toBe(true);
|
||||
expect(diff.totalAdded).toBe(2);
|
||||
expect(diff.totalRemoved).toBe(4);
|
||||
expect(diff.hasSpecialChanges).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('职责互斥(SoD)', () => {
|
||||
it('命中脚本生成 × 审核', () => {
|
||||
const conflicts = findDraftSodConflicts(
|
||||
draftOf({ permissions: ['script:write', 'script:review'] }),
|
||||
);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0]?.pairId).toBe('script-write-review');
|
||||
});
|
||||
|
||||
it('无交集时返回空数组', () => {
|
||||
expect(findDraftSodConflicts(draftOf())).toEqual([]);
|
||||
});
|
||||
|
||||
it('解析 40304 错误体 details.conflicts', () => {
|
||||
const conflicts = conflictsFromErrorDetails({
|
||||
conflicts: [
|
||||
{
|
||||
pairId: 'industry-manage-publish',
|
||||
label: '行业包编辑 × 行业包发布',
|
||||
permissions: ['industry:manage', 'industry:publish'],
|
||||
},
|
||||
{ pairId: 'bad', label: '非法', permissions: ['ghost:one', 'ghost:two'] },
|
||||
],
|
||||
});
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0]?.permissions).toEqual(['industry:manage', 'industry:publish']);
|
||||
expect(conflictsFromErrorDetails(null)).toEqual([]);
|
||||
expect(conflictsFromErrorDetails({ conflicts: 'bad' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('保存入参校验', () => {
|
||||
it('名称为空时返回字段错误', () => {
|
||||
const result = validateCreateDraft(createEmptyDraft());
|
||||
expect(result.input).toBeNull();
|
||||
expect(result.errors.name).toContain('请输入角色名称');
|
||||
});
|
||||
|
||||
it('新建入参把特殊权限放到独立字段,非客户组范围不带 targetIds', () => {
|
||||
const result = validateCreateDraft(
|
||||
draftOf({ dataScope: { type: 'department', targetIds: [GROUP_ID] } }),
|
||||
);
|
||||
expect(result.input === null).toBe(false);
|
||||
expect(result.input?.permissions).toHaveLength(5);
|
||||
expect(result.input?.specialPermissions).toEqual(['delivery:export']);
|
||||
expect(result.input?.dataScope).toEqual({ type: 'department' });
|
||||
});
|
||||
|
||||
it('更新系统角色只提交显示名', () => {
|
||||
const detail = buildDetail({ isSystem: true, code: 'super_admin', name: '超级管理员' });
|
||||
const result = validateUpdateDraft(detail, draftOf({ name: '超级管理员(改名)' }));
|
||||
expect(result.input).toEqual({ name: '超级管理员(改名)' });
|
||||
});
|
||||
|
||||
it('更新普通角色提交完整权限快照与数据范围', () => {
|
||||
const detail = buildDetail();
|
||||
const result = validateUpdateDraft(detail, draftOf({ name: '' }));
|
||||
expect(result.input).toBeNull();
|
||||
expect(result.errors.name).toBeTruthy();
|
||||
|
||||
const valid = validateUpdateDraft(detail, draftOf());
|
||||
expect(valid.input?.permissions).toHaveLength(5);
|
||||
expect(valid.input?.dataScope).toEqual({
|
||||
type: 'customer_groups',
|
||||
targetIds: [GROUP_ID],
|
||||
});
|
||||
});
|
||||
|
||||
it('保存结果文案包含影响用户数', () => {
|
||||
expect(
|
||||
saveResultMessage({ role: buildDetail(), affectedUserCount: 3, warnings: [] }),
|
||||
).toContain('3 名用户');
|
||||
expect(saveResultMessage({ role: buildDetail(), affectedUserCount: 0, warnings: [] })).toBe(
|
||||
'角色已保存',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
type CreateRoleInput,
|
||||
createRoleInputSchema,
|
||||
type DataScopeType,
|
||||
findSodConflicts,
|
||||
isPermissionCode,
|
||||
type PermissionCode,
|
||||
type RoleDataScopeInput,
|
||||
type RoleDetail,
|
||||
type RoleSaveResult,
|
||||
type SodConflict,
|
||||
type UpdateRoleInput,
|
||||
updateRoleInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
/** 编辑器 / 新建页共用的角色草稿(权限与特殊权限分桶,数据范围单选) */
|
||||
export interface RoleDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: readonly PermissionCode[];
|
||||
specialPermissions: readonly PermissionCode[];
|
||||
dataScope: {
|
||||
type: DataScopeType;
|
||||
targetIds: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DraftFromDetailOptions {
|
||||
/** 复制 / 模板场景:特殊权限不随角色带入(PRD §5.4.3) */
|
||||
includeSpecial?: boolean;
|
||||
/** 名称后缀,例如「 副本」 */
|
||||
nameSuffix?: string;
|
||||
}
|
||||
|
||||
export function draftFromDetail(
|
||||
detail: RoleDetail,
|
||||
options: DraftFromDetailOptions = {},
|
||||
): RoleDraft {
|
||||
const suffix = options.nameSuffix ?? '';
|
||||
return {
|
||||
name: `${detail.name}${suffix}`,
|
||||
description: detail.description,
|
||||
permissions: [...detail.permissions],
|
||||
specialPermissions: options.includeSpecial === true ? [...detail.specialPermissions] : [],
|
||||
dataScope: {
|
||||
type: detail.dataScope.type,
|
||||
targetIds: [...(detail.dataScope.targetIds ?? [])],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 空白草稿:默认最小数据范围(本人创建),特殊权限全关 */
|
||||
export function createEmptyDraft(): RoleDraft {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: [],
|
||||
specialPermissions: [],
|
||||
dataScope: { type: 'created_by_me', targetIds: [] },
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoleDraftDiff {
|
||||
addedPermissions: string[];
|
||||
removedPermissions: string[];
|
||||
addedSpecial: string[];
|
||||
removedSpecial: string[];
|
||||
nameChanged: boolean;
|
||||
descriptionChanged: boolean;
|
||||
scopeChanged: boolean;
|
||||
totalAdded: number;
|
||||
totalRemoved: number;
|
||||
totalChanges: number;
|
||||
hasPermissionChanges: boolean;
|
||||
hasSpecialChanges: boolean;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
function changedCodes(
|
||||
base: readonly PermissionCode[],
|
||||
next: readonly PermissionCode[],
|
||||
): { added: PermissionCode[]; removed: PermissionCode[] } {
|
||||
const baseSet = new Set(base);
|
||||
const nextSet = new Set(next);
|
||||
return {
|
||||
added: next.filter((code) => !baseSet.has(code)),
|
||||
removed: base.filter((code) => !nextSet.has(code)),
|
||||
};
|
||||
}
|
||||
|
||||
function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
const rightSet = new Set(right);
|
||||
return left.every((code) => rightSet.has(code));
|
||||
}
|
||||
|
||||
/** 草稿与基线对比:权限 diff + 基本信息 + 数据范围(保存影响面与脏检查同源) */
|
||||
export function diffRoleDraft(base: RoleDraft, draft: RoleDraft): RoleDraftDiff {
|
||||
const permissionDiff = changedCodes(base.permissions, draft.permissions);
|
||||
const specialDiff = changedCodes(base.specialPermissions, draft.specialPermissions);
|
||||
const nameChanged = base.name !== draft.name;
|
||||
const descriptionChanged = base.description !== draft.description;
|
||||
const scopeChanged =
|
||||
base.dataScope.type !== draft.dataScope.type ||
|
||||
!sameStringSet(base.dataScope.targetIds, draft.dataScope.targetIds);
|
||||
const totalAdded = permissionDiff.added.length + specialDiff.added.length;
|
||||
const totalRemoved = permissionDiff.removed.length + specialDiff.removed.length;
|
||||
const hasPermissionChanges = permissionDiff.added.length + permissionDiff.removed.length > 0;
|
||||
const hasSpecialChanges = specialDiff.added.length + specialDiff.removed.length > 0;
|
||||
return {
|
||||
addedPermissions: permissionDiff.added,
|
||||
removedPermissions: permissionDiff.removed,
|
||||
addedSpecial: specialDiff.added,
|
||||
removedSpecial: specialDiff.removed,
|
||||
nameChanged,
|
||||
descriptionChanged,
|
||||
scopeChanged,
|
||||
totalAdded,
|
||||
totalRemoved,
|
||||
totalChanges:
|
||||
totalAdded +
|
||||
totalRemoved +
|
||||
(nameChanged ? 1 : 0) +
|
||||
(descriptionChanged ? 1 : 0) +
|
||||
(scopeChanged ? 1 : 0),
|
||||
hasPermissionChanges,
|
||||
hasSpecialChanges,
|
||||
isDirty: totalAdded + totalRemoved > 0 || nameChanged || descriptionChanged || scopeChanged,
|
||||
};
|
||||
}
|
||||
|
||||
/** 前端实时 SoD 校验(超管豁免由调用方按 isSystem 传入空) */
|
||||
export function findDraftSodConflicts(draft: RoleDraft): SodConflict[] {
|
||||
return findSodConflicts([...draft.permissions, ...draft.specialPermissions]);
|
||||
}
|
||||
|
||||
export interface RoleDraftErrors {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RoleDraftValidation<TInput> {
|
||||
input: TInput | null;
|
||||
errors: RoleDraftErrors;
|
||||
}
|
||||
|
||||
function collectFieldErrors(
|
||||
issues: readonly { path: readonly PropertyKey[]; message: string }[],
|
||||
): RoleDraftErrors {
|
||||
const errors: RoleDraftErrors = {};
|
||||
for (const issue of issues) {
|
||||
const field = issue.path[0];
|
||||
if (field === 'name' && errors.name === undefined) {
|
||||
errors.name = issue.message;
|
||||
}
|
||||
if (field === 'description' && errors.description === undefined) {
|
||||
errors.description = issue.message;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function dataScopePayload(draft: RoleDraft): RoleDataScopeInput {
|
||||
if (draft.dataScope.type !== 'customer_groups') {
|
||||
return { type: draft.dataScope.type };
|
||||
}
|
||||
return { type: draft.dataScope.type, targetIds: [...draft.dataScope.targetIds] };
|
||||
}
|
||||
|
||||
/** 新建入参:与后端共用 createRoleInputSchema 做同源校验 */
|
||||
export function validateCreateDraft(draft: RoleDraft): RoleDraftValidation<CreateRoleInput> {
|
||||
const parsed = createRoleInputSchema.safeParse({
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
permissions: [...draft.permissions],
|
||||
specialPermissions: [...draft.specialPermissions],
|
||||
dataScope: dataScopePayload(draft),
|
||||
});
|
||||
if (parsed.success) {
|
||||
return { input: parsed.data, errors: {} };
|
||||
}
|
||||
return { input: null, errors: collectFieldErrors(parsed.error.issues) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新入参:系统角色仅显示名可改;普通角色提交完整权限快照与数据范围。
|
||||
* 后端按字段级 diff 计算影响面,因此这里始终发送当前草稿(未变更字段也一致)。
|
||||
*/
|
||||
export function validateUpdateDraft(
|
||||
detail: RoleDetail,
|
||||
draft: RoleDraft,
|
||||
): RoleDraftValidation<UpdateRoleInput> {
|
||||
const input: UpdateRoleInput = detail.isSystem
|
||||
? { name: draft.name.trim() }
|
||||
: {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
permissions: [...draft.permissions],
|
||||
specialPermissions: [...draft.specialPermissions],
|
||||
dataScope: dataScopePayload(draft),
|
||||
};
|
||||
const parsed = updateRoleInputSchema.safeParse(input);
|
||||
if (parsed.success) {
|
||||
return { input: parsed.data, errors: {} };
|
||||
}
|
||||
return { input: null, errors: collectFieldErrors(parsed.error.issues) };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function isPermissionPair(value: unknown): value is readonly [PermissionCode, PermissionCode] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === 2 &&
|
||||
typeof value[0] === 'string' &&
|
||||
typeof value[1] === 'string' &&
|
||||
isPermissionCode(value[0]) &&
|
||||
isPermissionCode(value[1])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 40304 错误体的 `details.conflicts`(后端与前端同一契约):
|
||||
* 用于保存竞态时把服务端判定回填到 SoD 错误条与冲突高亮。
|
||||
*/
|
||||
export function conflictsFromErrorDetails(details: unknown): SodConflict[] {
|
||||
if (!isRecord(details) || !Array.isArray(details.conflicts)) {
|
||||
return [];
|
||||
}
|
||||
const conflicts: SodConflict[] = [];
|
||||
for (const item of details.conflicts) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof item.pairId !== 'string' || typeof item.label !== 'string') {
|
||||
continue;
|
||||
}
|
||||
if (!isPermissionPair(item.permissions)) {
|
||||
continue;
|
||||
}
|
||||
conflicts.push({
|
||||
pairId: item.pairId,
|
||||
label: item.label,
|
||||
permissions: [item.permissions[0], item.permissions[1]],
|
||||
});
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/** 保存成功结果:特殊权限变化时用于 toast 文案 */
|
||||
export function saveResultMessage(result: RoleSaveResult): string {
|
||||
const affected = result.affectedUserCount;
|
||||
const base = '角色已保存';
|
||||
return affected > 0 ? `${base},${String(affected)} 名用户将在下一次请求生效` : base;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/* eslint-disable max-lines-per-function -- 编辑器状态与动作集中在一个 Hook,便于表单行为单点维护 */
|
||||
import {
|
||||
isPermissionCode,
|
||||
type PermissionCode,
|
||||
type PermissionModuleNode,
|
||||
type RoleDetail,
|
||||
} from '@lingxi/shared';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
diffRoleDraft,
|
||||
draftFromDetail,
|
||||
findDraftSodConflicts,
|
||||
type RoleDraft,
|
||||
type RoleDraftDiff,
|
||||
} from './role-utils';
|
||||
|
||||
export interface RoleEditorState {
|
||||
base: RoleDraft;
|
||||
draft: RoleDraft;
|
||||
diff: RoleDraftDiff;
|
||||
/** SoD 冲突(系统角色豁免,恒为空) */
|
||||
conflicts: ReturnType<typeof findDraftSodConflicts>;
|
||||
conflictCodes: ReadonlySet<string>;
|
||||
isDirty: boolean;
|
||||
setName: (name: string) => void;
|
||||
setDescription: (description: string) => void;
|
||||
togglePermission: (code: string) => void;
|
||||
toggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
|
||||
setScopeType: (type: RoleDraft['dataScope']['type']) => void;
|
||||
setTargetIds: (targetIds: readonly string[]) => void;
|
||||
setSpecialPermission: (code: string, granted: boolean) => void;
|
||||
reset: () => void;
|
||||
markSaved: () => void;
|
||||
}
|
||||
|
||||
function cloneDraft(draft: RoleDraft): RoleDraft {
|
||||
return {
|
||||
...draft,
|
||||
permissions: [...draft.permissions],
|
||||
specialPermissions: [...draft.specialPermissions],
|
||||
dataScope: {
|
||||
type: draft.dataScope.type,
|
||||
targetIds: [...draft.dataScope.targetIds],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 角色编辑器状态:草稿 + 基线 + SoD / diff 派生值(纯计算见 role-utils) */
|
||||
export function useRoleEditor(detail: RoleDetail): RoleEditorState {
|
||||
const [base, setBase] = useState<RoleDraft>(() =>
|
||||
draftFromDetail(detail, { includeSpecial: true }),
|
||||
);
|
||||
const [draft, setDraft] = useState<RoleDraft>(() =>
|
||||
draftFromDetail(detail, { includeSpecial: true }),
|
||||
);
|
||||
|
||||
const setName = useCallback((name: string) => {
|
||||
setDraft((current) => ({ ...current, name }));
|
||||
}, []);
|
||||
|
||||
const setDescription = useCallback((description: string) => {
|
||||
setDraft((current) => ({ ...current, description }));
|
||||
}, []);
|
||||
|
||||
const togglePermission = useCallback((code: string) => {
|
||||
if (!isPermissionCode(code)) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => {
|
||||
const has = current.permissions.includes(code);
|
||||
return {
|
||||
...current,
|
||||
permissions: has
|
||||
? current.permissions.filter((item) => item !== code)
|
||||
: [...current.permissions, code],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleModule = useCallback((module: PermissionModuleNode, selectAll: boolean) => {
|
||||
const normalCodes: PermissionCode[] = module.permissions
|
||||
.filter((permission) => !permission.isSpecial && isPermissionCode(permission.code))
|
||||
.map((permission) => permission.code)
|
||||
.filter(isPermissionCode);
|
||||
setDraft((current) => {
|
||||
const next = new Set(current.permissions);
|
||||
for (const code of normalCodes) {
|
||||
if (selectAll) {
|
||||
next.add(code);
|
||||
} else {
|
||||
next.delete(code);
|
||||
}
|
||||
}
|
||||
return { ...current, permissions: [...next] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setScopeType = useCallback((type: RoleDraft['dataScope']['type']) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
dataScope: {
|
||||
type,
|
||||
targetIds: type === 'customer_groups' ? current.dataScope.targetIds : [],
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTargetIds = useCallback((targetIds: readonly string[]) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
dataScope: { type: current.dataScope.type, targetIds: [...targetIds] },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setSpecialPermission = useCallback((code: string, granted: boolean) => {
|
||||
if (!isPermissionCode(code)) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => {
|
||||
const has = current.specialPermissions.includes(code);
|
||||
if (granted === has) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
specialPermissions: granted
|
||||
? [...current.specialPermissions, code]
|
||||
: current.specialPermissions.filter((item) => item !== code),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setDraft(cloneDraft(base));
|
||||
}, [base]);
|
||||
|
||||
const markSaved = useCallback(() => {
|
||||
setBase(cloneDraft(draft));
|
||||
}, [draft]);
|
||||
|
||||
const diff = useMemo(() => diffRoleDraft(base, draft), [base, draft]);
|
||||
const conflicts = useMemo(
|
||||
() => (detail.isSystem ? [] : findDraftSodConflicts(draft)),
|
||||
[detail.isSystem, draft],
|
||||
);
|
||||
const conflictCodes = useMemo(() => {
|
||||
const codes = new Set<string>();
|
||||
for (const conflict of conflicts) {
|
||||
codes.add(conflict.permissions[0]);
|
||||
codes.add(conflict.permissions[1]);
|
||||
}
|
||||
return codes;
|
||||
}, [conflicts]);
|
||||
|
||||
return {
|
||||
base,
|
||||
draft,
|
||||
diff,
|
||||
conflicts,
|
||||
conflictCodes,
|
||||
isDirty: diff.isDirty,
|
||||
setName,
|
||||
setDescription,
|
||||
togglePermission,
|
||||
toggleModule,
|
||||
setScopeType,
|
||||
setTargetIds,
|
||||
setSpecialPermission,
|
||||
reset,
|
||||
markSaved,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,21 @@ const UserNewPage = lazy(async () => {
|
||||
return { default: module.UserNewPage };
|
||||
});
|
||||
|
||||
const RolesPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/roles/RolesPage');
|
||||
return { default: module.RolesPage };
|
||||
});
|
||||
|
||||
const RoleEditorPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/roles/RoleEditorPage');
|
||||
return { default: module.RoleEditorPage };
|
||||
});
|
||||
|
||||
const RoleNewPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/roles/RoleNewPage');
|
||||
return { default: module.RoleNewPage };
|
||||
});
|
||||
|
||||
function withSkeleton(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||
}
|
||||
@@ -82,6 +97,30 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.ROLE_VIEW}>
|
||||
{withSkeleton(<RolesPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/roles/new',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.ROLE_MANAGE}>
|
||||
{withSkeleton(<RoleNewPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/roles/:id',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.ROLE_VIEW}>
|
||||
{withSkeleton(<RoleEditorPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':moduleKey/:itemKey?',
|
||||
lazy: async () => ({
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loginPath, ROUTES, sanitizeRedirect, userDetailPath } from './routes';
|
||||
import {
|
||||
loginPath,
|
||||
roleDetailPath,
|
||||
roleNewPath,
|
||||
ROUTES,
|
||||
sanitizeRedirect,
|
||||
userDetailPath,
|
||||
} from './routes';
|
||||
|
||||
describe('路由常量与登录回跳', () => {
|
||||
it('用户路由遵循 /system/users 模块化约定', () => {
|
||||
@@ -11,6 +18,19 @@ describe('路由常量与登录回跳', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('角色路由包含列表 / 新建 / 编辑器深链', () => {
|
||||
expect(ROUTES.system.roles).toBe('/system/roles');
|
||||
expect(ROUTES.system.roleNew).toBe('/system/roles/new');
|
||||
expect(ROUTES.system.roleDetail).toBe('/system/roles/:id');
|
||||
expect(roleDetailPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe(
|
||||
'/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
);
|
||||
expect(roleNewPath()).toBe('/system/roles/new');
|
||||
expect(roleNewPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe(
|
||||
'/system/roles/new?from=01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizeRedirect 只接受站内路径', () => {
|
||||
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
|
||||
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
|
||||
|
||||
@@ -12,6 +12,9 @@ export const ROUTES = {
|
||||
users: '/system/users',
|
||||
userNew: '/system/users/new',
|
||||
userDetail: '/system/users/:id',
|
||||
roles: '/system/roles',
|
||||
roleNew: '/system/roles/new',
|
||||
roleDetail: '/system/roles/:id',
|
||||
},
|
||||
unauthorized: '/401',
|
||||
forbidden: '/403',
|
||||
@@ -26,6 +29,9 @@ export type RoutePath =
|
||||
| typeof ROUTES.system.users
|
||||
| typeof ROUTES.system.userNew
|
||||
| typeof ROUTES.system.userDetail
|
||||
| typeof ROUTES.system.roles
|
||||
| typeof ROUTES.system.roleNew
|
||||
| typeof ROUTES.system.roleDetail
|
||||
| typeof ROUTES.unauthorized
|
||||
| typeof ROUTES.forbidden
|
||||
| typeof ROUTES.notFound
|
||||
@@ -36,6 +42,18 @@ export function userDetailPath(userId: string): string {
|
||||
return `${ROUTES.system.users}/${encodeURIComponent(userId)}`;
|
||||
}
|
||||
|
||||
/** 角色详情 / 编辑器深链:`/system/roles/{id}` */
|
||||
export function roleDetailPath(roleId: string): string {
|
||||
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
|
||||
}
|
||||
|
||||
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
|
||||
export function roleNewPath(sourceRoleId?: string): string {
|
||||
return sourceRoleId === undefined
|
||||
? ROUTES.system.roleNew
|
||||
: `${ROUTES.system.roleNew}?from=${encodeURIComponent(sourceRoleId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验回跳地址:只允许站内绝对路径(以单个 `/` 开头),
|
||||
* 拒绝协议相对地址(`//evil.com`)与外部 URL,避免开放重定向。
|
||||
|
||||
@@ -24,7 +24,7 @@ const me: MeResponse = {
|
||||
},
|
||||
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
|
||||
permissions: ['user:view', 'user:manage'],
|
||||
dataScope: { type: 'all' },
|
||||
dataScopes: [{ type: 'all' }],
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user