feat: 分层 RBAC 权限管理系统
后端: - 新增 Role / RolePermission 实体(自动 seed 系统角色) - PermissionService——通过 isAdmin / TenantMember 链路解析用户权限 - @Permission() 装饰器 + PermissionsGuard 守卫 - /api/permissions 和 /api/roles REST API - UserController 内联 role 检查迁移到 @Permission() - PermissionModule 全局注册 前端: - usePermissions hook——获取当前用户权限集 - PermissionGate 组件级门控 - PermissionSettingsView——角色列表+权限矩阵编辑页面 - SettingsView 新增「权限管理」Tab(仅 admin 可见) - 权限预览(26 项,7 分类) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ import { TenantModule } from './tenant/tenant.module';
|
|||||||
import { SuperAdminModule } from './super-admin/super-admin.module';
|
import { SuperAdminModule } from './super-admin/super-admin.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { FeishuModule } from './feishu/feishu.module';
|
import { FeishuModule } from './feishu/feishu.module';
|
||||||
|
import { PermissionModule } from './auth/permission/permission.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -82,6 +83,7 @@ import { FeishuModule } from './feishu/feishu.module';
|
|||||||
SuperAdminModule,
|
SuperAdminModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
FeishuModule,
|
FeishuModule,
|
||||||
|
PermissionModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 所有可用权限的常量定义
|
||||||
|
* 格式: resource:action
|
||||||
|
*
|
||||||
|
* 分类说明:
|
||||||
|
* - user: 用户管理
|
||||||
|
* - tenant: 租户管理
|
||||||
|
* - kb: 知识库
|
||||||
|
* - assess: 考核评估
|
||||||
|
* - model: 模型配置
|
||||||
|
* - plugin: 插件管理
|
||||||
|
* - settings: 系统设置
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
// ── 用户管理 ──
|
||||||
|
USER_VIEW: 'user:view',
|
||||||
|
USER_CREATE: 'user:create',
|
||||||
|
USER_EDIT: 'user:edit',
|
||||||
|
USER_DELETE: 'user:delete',
|
||||||
|
USER_ROLE: 'user:role', // 修改他人角色/权限
|
||||||
|
USER_PASSWORD: 'user:password', // 重置他人密码
|
||||||
|
|
||||||
|
// ── 租户管理 ──
|
||||||
|
TENANT_VIEW: 'tenant:view',
|
||||||
|
TENANT_CREATE: 'tenant:create',
|
||||||
|
TENANT_EDIT: 'tenant:edit',
|
||||||
|
TENANT_DELETE: 'tenant:delete',
|
||||||
|
TENANT_MEMBERS: 'tenant:members',
|
||||||
|
|
||||||
|
// ── 知识库 ──
|
||||||
|
KB_VIEW: 'kb:view',
|
||||||
|
KB_CREATE: 'kb:create',
|
||||||
|
KB_EDIT: 'kb:edit',
|
||||||
|
KB_DELETE: 'kb:delete',
|
||||||
|
KB_PUBLISH: 'kb:publish',
|
||||||
|
|
||||||
|
// ── 考核评估 ──
|
||||||
|
ASSESS_VIEW: 'assess:view',
|
||||||
|
ASSESS_MANAGE: 'assess:manage',
|
||||||
|
ASSESS_TEMPLATE: 'assess:template',
|
||||||
|
ASSESS_BANK: 'assess:bank',
|
||||||
|
|
||||||
|
// ── 模型配置 ──
|
||||||
|
MODEL_VIEW: 'model:view',
|
||||||
|
MODEL_CONFIG: 'model:config',
|
||||||
|
|
||||||
|
// ── 插件管理 ──
|
||||||
|
PLUGIN_VIEW: 'plugin:view',
|
||||||
|
PLUGIN_MANAGE: 'plugin:manage',
|
||||||
|
|
||||||
|
// ── 系统设置 ──
|
||||||
|
SETTINGS_VIEW: 'settings:view',
|
||||||
|
SETTINGS_SYSTEM: 'settings:system',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PermissionKey = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
|
export const ALL_PERMISSIONS = Object.values(PERMISSIONS) as PermissionKey[];
|
||||||
|
|
||||||
|
/** 权限分类元数据——给前端渲染用 */
|
||||||
|
export interface PermissionMeta {
|
||||||
|
key: PermissionKey;
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERMISSION_META: PermissionMeta[] = [
|
||||||
|
// ── 用户管理 ──
|
||||||
|
{ key: PERMISSIONS.USER_VIEW, category: '用户管理', label: '查看用户', description: '查看用户列表和基本信息' },
|
||||||
|
{ key: PERMISSIONS.USER_CREATE, category: '用户管理', label: '创建用户', description: '添加新用户到系统' },
|
||||||
|
{ key: PERMISSIONS.USER_EDIT, category: '用户管理', label: '编辑用户', description: '修改用户基本信息' },
|
||||||
|
{ key: PERMISSIONS.USER_DELETE, category: '用户管理', label: '删除用户', description: '从系统删除用户' },
|
||||||
|
{ key: PERMISSIONS.USER_ROLE, category: '用户管理', label: '管理角色', description: '修改用户角色和权限' },
|
||||||
|
{ key: PERMISSIONS.USER_PASSWORD, category: '用户管理', label: '重置密码', description: '重置其他用户的密码' },
|
||||||
|
|
||||||
|
// ── 租户管理 ──
|
||||||
|
{ key: PERMISSIONS.TENANT_VIEW, category: '租户管理', label: '查看租户', description: '查看租户信息和成员' },
|
||||||
|
{ key: PERMISSIONS.TENANT_CREATE, category: '租户管理', label: '创建租户', description: '创建新的租户' },
|
||||||
|
{ key: PERMISSIONS.TENANT_EDIT, category: '租户管理', label: '编辑租户', description: '修改租户设置' },
|
||||||
|
{ key: PERMISSIONS.TENANT_DELETE, category: '租户管理', label: '删除租户', description: '删除租户' },
|
||||||
|
{ key: PERMISSIONS.TENANT_MEMBERS, category: '租户管理', label: '管理成员', description: '添加/移除租户成员' },
|
||||||
|
|
||||||
|
// ── 知识库 ──
|
||||||
|
{ key: PERMISSIONS.KB_VIEW, category: '知识库', label: '查看知识库', description: '查看知识库内容' },
|
||||||
|
{ key: PERMISSIONS.KB_CREATE, category: '知识库', label: '创建知识库', description: '创建新的知识库' },
|
||||||
|
{ key: PERMISSIONS.KB_EDIT, category: '知识库', label: '编辑知识库', description: '编辑知识库内容' },
|
||||||
|
{ key: PERMISSIONS.KB_DELETE, category: '知识库', label: '删除知识库', description: '删除知识库' },
|
||||||
|
{ key: PERMISSIONS.KB_PUBLISH, category: '知识库', label: '发布知识库', description: '将知识库发布上线' },
|
||||||
|
|
||||||
|
// ── 考核评估 ──
|
||||||
|
{ key: PERMISSIONS.ASSESS_VIEW, category: '考核评估', label: '查看考核', description: '查看考核结果和报告' },
|
||||||
|
{ key: PERMISSIONS.ASSESS_MANAGE, category: '考核评估', label: '管理考核', description: '管理考核会话' },
|
||||||
|
{ key: PERMISSIONS.ASSESS_TEMPLATE, category: '考核评估', label: '管理模板', description: '创建和编辑考核模板' },
|
||||||
|
{ key: PERMISSIONS.ASSESS_BANK, category: '考核评估', label: '管理题库', description: '管理题库内容' },
|
||||||
|
|
||||||
|
// ── 模型配置 ──
|
||||||
|
{ key: PERMISSIONS.MODEL_VIEW, category: '模型配置', label: '查看模型', description: '查看模型配置' },
|
||||||
|
{ key: PERMISSIONS.MODEL_CONFIG, category: '模型配置', label: '配置模型', description: '修改模型配置' },
|
||||||
|
|
||||||
|
// ── 插件管理 ──
|
||||||
|
{ key: PERMISSIONS.PLUGIN_VIEW, category: '插件管理', label: '查看插件', description: '查看插件列表' },
|
||||||
|
{ key: PERMISSIONS.PLUGIN_MANAGE, category: '插件管理', label: '管理插件', description: '启停和配置插件' },
|
||||||
|
|
||||||
|
// ── 系统设置 ──
|
||||||
|
{ key: PERMISSIONS.SETTINGS_VIEW, category: '系统设置', label: '查看设置', description: '查看系统设置' },
|
||||||
|
{ key: PERMISSIONS.SETTINGS_SYSTEM, category: '系统设置', label: '系统设置', description: '修改系统级设置(仅超级管理员)' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Get, Request, UseGuards } from '@nestjs/common';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
import { CombinedAuthGuard } from '../combined-auth.guard';
|
||||||
|
|
||||||
|
@Controller('permissions')
|
||||||
|
@UseGuards(CombinedAuthGuard)
|
||||||
|
export class PermissionController {
|
||||||
|
constructor(private readonly permissionService: PermissionService) {}
|
||||||
|
|
||||||
|
/** 获取所有可用权限(含分类) */
|
||||||
|
@Get()
|
||||||
|
getAll() {
|
||||||
|
return this.permissionService.getPermissionsByCategory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有权限的扁平元数据列表 */
|
||||||
|
@Get('meta')
|
||||||
|
getMeta() {
|
||||||
|
return this.permissionService.getAllPermissionMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户在活动租户下的权限集 */
|
||||||
|
@Get('mine')
|
||||||
|
async getMine(@Request() req) {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const tenantId = req.tenantId || req.user.tenantId;
|
||||||
|
const perms = await this.permissionService.getUserPermissions(userId, tenantId);
|
||||||
|
return { permissions: [...perms] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const PERMISSIONS_KEY = 'permissions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限装饰器——标记路由需要的权限
|
||||||
|
* 多个权限之间为 OR 关系(有任一匹配即可)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Permission('user:view') // 需要 user:view 权限
|
||||||
|
* @Permission('user:create', 'user:edit') // 需要 user:create 或 user:edit
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Permission = (...permissions: string[]) =>
|
||||||
|
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
import { PERMISSIONS_KEY } from './permission.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限守卫——配合 @Permission() 装饰器使用
|
||||||
|
*
|
||||||
|
* 在 CombinedAuthGuard 和 RolesGuard 之后运行
|
||||||
|
* 检查 request.user 是否有 @Permission() 指定的任一权限
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly permissionService: PermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||||
|
PERMISSIONS_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
const userId = user.id;
|
||||||
|
const tenantId = request.tenantId || user.tenantId;
|
||||||
|
if (!userId || !tenantId) return false;
|
||||||
|
|
||||||
|
const userPermissions = await this.permissionService.getUserPermissions(userId, tenantId);
|
||||||
|
|
||||||
|
// OR 模式:任一权限匹配即可
|
||||||
|
const hasPermission = requiredPermissions.some(p => userPermissions.has(p));
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw new ForbiddenException(`需要权限: ${requiredPermissions.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Role } from './role.entity';
|
||||||
|
import { RolePermission } from './role-permission.entity';
|
||||||
|
import { TenantMember } from '../../tenant/tenant-member.entity';
|
||||||
|
import { User } from '../../user/user.entity';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
import { PermissionController } from './permission.controller';
|
||||||
|
import { RoleController } from './role.controller';
|
||||||
|
import { PermissionsGuard } from './permission.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Role, RolePermission, TenantMember, User]),
|
||||||
|
],
|
||||||
|
controllers: [PermissionController, RoleController],
|
||||||
|
providers: [PermissionService, PermissionsGuard],
|
||||||
|
exports: [PermissionService, PermissionsGuard],
|
||||||
|
})
|
||||||
|
export class PermissionModule {}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Role } from './role.entity';
|
||||||
|
import { RolePermission } from './role-permission.entity';
|
||||||
|
import { ALL_PERMISSIONS, PERMISSION_META, PermissionKey, PermissionMeta } from './permission.constants';
|
||||||
|
import { UserRole } from '../../user/user-role.enum';
|
||||||
|
import { TenantMember } from '../../tenant/tenant-member.entity';
|
||||||
|
import { User } from '../../user/user.entity';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色预设——系统内置角色默认挂载的权限
|
||||||
|
*/
|
||||||
|
const ROLE_DEFAULT_PERMISSIONS: Record<string, PermissionKey[]> = {
|
||||||
|
[UserRole.SUPER_ADMIN]: [...ALL_PERMISSIONS],
|
||||||
|
|
||||||
|
[UserRole.TENANT_ADMIN]: [
|
||||||
|
// 用户管理(不含删除和角色管理——安全考虑)
|
||||||
|
'user:view', 'user:create', 'user:edit', 'user:password',
|
||||||
|
// 租户管理(只能查看和编辑自己的)
|
||||||
|
'tenant:view', 'tenant:edit', 'tenant:members',
|
||||||
|
// 知识库
|
||||||
|
'kb:view', 'kb:create', 'kb:edit', 'kb:delete', 'kb:publish',
|
||||||
|
// 考核评估
|
||||||
|
'assess:view', 'assess:manage', 'assess:template', 'assess:bank',
|
||||||
|
// 模型配置
|
||||||
|
'model:view', 'model:config',
|
||||||
|
// 插件
|
||||||
|
'plugin:view', 'plugin:manage',
|
||||||
|
// 设置
|
||||||
|
'settings:view',
|
||||||
|
],
|
||||||
|
|
||||||
|
[UserRole.USER]: [
|
||||||
|
'kb:view', 'kb:create', 'kb:edit',
|
||||||
|
'assess:view',
|
||||||
|
'plugin:view',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PermissionService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Role)
|
||||||
|
private readonly roleRepository: Repository<Role>,
|
||||||
|
@InjectRepository(RolePermission)
|
||||||
|
private readonly rolePermissionRepository: Repository<RolePermission>,
|
||||||
|
@InjectRepository(TenantMember)
|
||||||
|
private readonly tenantMemberRepository: Repository<TenantMember>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时自动种子化系统角色和权限
|
||||||
|
*/
|
||||||
|
async onModuleInit() {
|
||||||
|
const isTest = this.configService.get<string>('NODE_ENV') === 'test';
|
||||||
|
if (isTest) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.roleRepository.count({ where: { isSystem: true } });
|
||||||
|
if (existing > 0) {
|
||||||
|
this.logger.log(`[Permission Seed] ${existing} 个系统角色已存在,跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.seedSystemRoles();
|
||||||
|
this.logger.log('[Permission Seed] ✅ 系统角色和权限已初始化');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('[Permission Seed] ❌ 初始化失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedSystemRoles() {
|
||||||
|
const roles = [
|
||||||
|
{ name: UserRole.SUPER_ADMIN, baseRole: UserRole.SUPER_ADMIN },
|
||||||
|
{ name: UserRole.TENANT_ADMIN, baseRole: UserRole.TENANT_ADMIN },
|
||||||
|
{ name: UserRole.USER, baseRole: UserRole.USER },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const r of roles) {
|
||||||
|
const role = await this.roleRepository.save({
|
||||||
|
name: r.name,
|
||||||
|
description: this.getRoleDescription(r.name as UserRole),
|
||||||
|
isSystem: true,
|
||||||
|
baseRole: r.baseRole,
|
||||||
|
tenantId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const perms = ROLE_DEFAULT_PERMISSIONS[r.name] || [];
|
||||||
|
if (perms.length > 0) {
|
||||||
|
await this.rolePermissionRepository.save(
|
||||||
|
perms.map(key => ({ roleId: role.id, permissionKey: key })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log(` - ${r.name}: ${perms.length} 项权限`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRoleDescription(role: UserRole): string {
|
||||||
|
switch (role) {
|
||||||
|
case UserRole.SUPER_ADMIN: return '全局超级管理员——拥有系统全部权限';
|
||||||
|
case UserRole.TENANT_ADMIN: return '租户管理员——管理本租户内的用户和资源';
|
||||||
|
case UserRole.USER: return '普通用户——使用系统功能';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────── 角色 CRUD ────────────
|
||||||
|
|
||||||
|
async findAllRoles(tenantId?: string): Promise<Role[]> {
|
||||||
|
const where: any[] = [{ isSystem: true, tenantId: null }];
|
||||||
|
if (tenantId) {
|
||||||
|
where.push({ tenantId, isSystem: false });
|
||||||
|
}
|
||||||
|
return this.roleRepository.find({ where, order: { isSystem: 'DESC', name: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRoleById(id: string): Promise<Role | null> {
|
||||||
|
return this.roleRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRole(name: string, description: string, tenantId: string): Promise<Role> {
|
||||||
|
const existing = await this.roleRepository.findOne({ where: { name } });
|
||||||
|
if (existing) throw new Error(`角色名 "${name}" 已存在`);
|
||||||
|
|
||||||
|
return this.roleRepository.save({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isSystem: false,
|
||||||
|
baseRole: null,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(id: string, data: { name?: string; description?: string }): Promise<Role> {
|
||||||
|
const role = await this.roleRepository.findOne({ where: { id } });
|
||||||
|
if (!role) throw new Error('角色不存在');
|
||||||
|
if (role.isSystem) throw new Error('系统角色不可编辑');
|
||||||
|
|
||||||
|
if (data.name) role.name = data.name;
|
||||||
|
if (data.description !== undefined) role.description = data.description;
|
||||||
|
return this.roleRepository.save(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRole(id: string): Promise<void> {
|
||||||
|
const role = await this.roleRepository.findOne({ where: { id } });
|
||||||
|
if (!role) throw new Error('角色不存在');
|
||||||
|
if (role.isSystem) throw new Error('系统角色不可删除');
|
||||||
|
await this.roleRepository.remove(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────── 权限管理 ────────────
|
||||||
|
|
||||||
|
async getRolePermissions(roleId: string): Promise<string[]> {
|
||||||
|
const rps = await this.rolePermissionRepository.find({
|
||||||
|
where: { roleId },
|
||||||
|
});
|
||||||
|
return rps.map(rp => rp.permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRolePermissions(roleId: string, permissionKeys: string[]): Promise<void> {
|
||||||
|
const role = await this.roleRepository.findOne({ where: { id: roleId } });
|
||||||
|
if (!role) throw new Error('角色不存在');
|
||||||
|
|
||||||
|
// 验证权限键是否有效
|
||||||
|
const valid = ALL_PERMISSIONS;
|
||||||
|
const invalid = permissionKeys.filter(k => !valid.includes(k as any));
|
||||||
|
if (invalid.length > 0) throw new Error(`无效的权限: ${invalid.join(', ')}`);
|
||||||
|
|
||||||
|
// 替换角色的所有权限
|
||||||
|
await this.rolePermissionRepository.delete({ roleId });
|
||||||
|
if (permissionKeys.length > 0) {
|
||||||
|
await this.rolePermissionRepository.save(
|
||||||
|
permissionKeys.map(key => ({ roleId, permissionKey: key })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────── 用户权限解析 ────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户在指定租户下的最终权限集
|
||||||
|
*
|
||||||
|
* 解析链路:
|
||||||
|
* 1. 如果是 global admin(isAdmin=true),直接返回所有权限
|
||||||
|
* 2. 通过 TenantMember.role → 对应 role → role_permissions
|
||||||
|
*/
|
||||||
|
async getUserPermissions(userId: string, tenantId: string): Promise<Set<string>> {
|
||||||
|
// 检查全局管理员(遗留 isAdmin 字段)
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
select: ['id', 'isAdmin'],
|
||||||
|
});
|
||||||
|
if (user?.isAdmin) {
|
||||||
|
const superAdminRole = await this.roleRepository.findOne({
|
||||||
|
where: { baseRole: UserRole.SUPER_ADMIN, isSystem: true },
|
||||||
|
});
|
||||||
|
if (superAdminRole) {
|
||||||
|
const perms = await this.getRolePermissions(superAdminRole.id);
|
||||||
|
return new Set(perms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过租户成员角色
|
||||||
|
const membership = await this.tenantMemberRepository.findOne({
|
||||||
|
where: { userId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) return new Set();
|
||||||
|
|
||||||
|
const role = await this.roleRepository.findOne({
|
||||||
|
where: { baseRole: membership.role, isSystem: true },
|
||||||
|
});
|
||||||
|
if (!role) return new Set();
|
||||||
|
|
||||||
|
const perms = await this.getRolePermissions(role.id);
|
||||||
|
return new Set(perms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有指定权限
|
||||||
|
*/
|
||||||
|
async checkPermission(userId: string, tenantId: string, permissionKey: string): Promise<boolean> {
|
||||||
|
// 先尝试从请求级缓存获取(由 PermissionsGuard 设置)
|
||||||
|
const perms = await this.getUserPermissions(userId, tenantId);
|
||||||
|
return perms.has(permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────── 元数据 ────────────
|
||||||
|
|
||||||
|
getAllPermissionMeta(): PermissionMeta[] {
|
||||||
|
return PERMISSION_META;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPermissionsByCategory(): Record<string, PermissionMeta[]> {
|
||||||
|
const grouped: Record<string, PermissionMeta[]> = {};
|
||||||
|
for (const meta of PERMISSION_META) {
|
||||||
|
if (!grouped[meta.category]) grouped[meta.category] = [];
|
||||||
|
grouped[meta.category].push(meta);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Role } from './role.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色-权限关联表
|
||||||
|
* 每个角色可以挂载多个权限
|
||||||
|
*/
|
||||||
|
@Entity('role_permissions')
|
||||||
|
export class RolePermission {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'role_id' })
|
||||||
|
roleId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Role, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'role_id' })
|
||||||
|
role: Role;
|
||||||
|
|
||||||
|
@Column({ name: 'permission_key', length: 50 })
|
||||||
|
permissionKey: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Request,
|
||||||
|
UseGuards,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
import { CombinedAuthGuard } from '../combined-auth.guard';
|
||||||
|
import { RolesGuard } from '../roles.guard';
|
||||||
|
import { Roles } from '../roles.decorator';
|
||||||
|
import { UserRole } from '../../user/user-role.enum';
|
||||||
|
|
||||||
|
@Controller('roles')
|
||||||
|
@UseGuards(CombinedAuthGuard, RolesGuard)
|
||||||
|
export class RoleController {
|
||||||
|
constructor(private readonly permissionService: PermissionService) {}
|
||||||
|
|
||||||
|
/** 列出角色(系统角色 + 租户自定义角色) */
|
||||||
|
@Get()
|
||||||
|
@Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN)
|
||||||
|
async findAll(@Request() req) {
|
||||||
|
const tenantId = req.tenantId || req.user.tenantId;
|
||||||
|
return this.permissionService.findAllRoles(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个角色 */
|
||||||
|
@Get(':id')
|
||||||
|
@Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN)
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
const role = await this.permissionService.findRoleById(id);
|
||||||
|
if (!role) throw new NotFoundException('角色不存在');
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建自定义角色 */
|
||||||
|
@Post()
|
||||||
|
@Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN)
|
||||||
|
async create(@Body() body: { name: string; description?: string }, @Request() req) {
|
||||||
|
if (!body.name) throw new BadRequestException('角色名不能为空');
|
||||||
|
const tenantId = req.tenantId || req.user.tenantId;
|
||||||
|
try {
|
||||||
|
return await this.permissionService.createRole(body.name, body.description || '', tenantId);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new BadRequestException(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改角色基本信息 */
|
||||||
|
@Put(':id')
|
||||||
|
@Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN)
|
||||||
|
async update(@Param('id') id: string, @Body() body: { name?: string; description?: string }) {
|
||||||
|
try {
|
||||||
|
return await this.permissionService.updateRole(id, body);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new BadRequestException(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除自定义角色 */
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN)
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
try {
|
||||||
|
await this.permissionService.deleteRole(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new BadRequestException(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取角色的权限列表 */
|
||||||
|
@Get(':id/permissions')
|
||||||
|
@Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN)
|
||||||
|
async getPermissions(@Param('id') id: string) {
|
||||||
|
const perms = await this.permissionService.getRolePermissions(id);
|
||||||
|
return { permissions: perms };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置角色的权限(全量替换) */
|
||||||
|
@Put(':id/permissions')
|
||||||
|
@Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN)
|
||||||
|
async setPermissions(@Param('id') id: string, @Body() body: { permissions: string[] }) {
|
||||||
|
if (!Array.isArray(body.permissions)) {
|
||||||
|
throw new BadRequestException('permissions 必须是数组');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.permissionService.setRolePermissions(id, body.permissions);
|
||||||
|
return { success: true, permissions: body.permissions };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new BadRequestException(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserRole } from '../../user/user-role.enum';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色表
|
||||||
|
* is_system = true: 系统内置角色(SUPER_ADMIN/TENANT_ADMIN/USER),不可删除
|
||||||
|
* tenant_id = null: 系统级角色(所有租户可见)
|
||||||
|
* tenant_id != null: 租户自定义角色
|
||||||
|
*/
|
||||||
|
@Entity('roles')
|
||||||
|
export class Role {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true, length: 50 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/** 是否为系统内置角色 */
|
||||||
|
@Column({ name: 'is_system', default: false })
|
||||||
|
isSystem: boolean;
|
||||||
|
|
||||||
|
/** 关联的内置角色 enum(仅 is_system=true 时有值) */
|
||||||
|
@Column({
|
||||||
|
name: 'base_role',
|
||||||
|
type: 'simple-enum',
|
||||||
|
enum: UserRole,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
baseRole: UserRole | null;
|
||||||
|
|
||||||
|
/** 所属租户:null=系统级,非 null=租户自定义 */
|
||||||
|
@Column({ name: 'tenant_id', nullable: true, type: 'text' })
|
||||||
|
tenantId: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import { UpdateUserDto } from './dto/update-user.dto';
|
|||||||
import { I18nService } from '../i18n/i18n.service';
|
import { I18nService } from '../i18n/i18n.service';
|
||||||
import { UserRole } from './user-role.enum';
|
import { UserRole } from './user-role.enum';
|
||||||
import { UserSettingService } from './user-setting.service';
|
import { UserSettingService } from './user-setting.service';
|
||||||
|
import { Permission } from '../auth/permission/permission.decorator';
|
||||||
|
import { PermissionsGuard } from '../auth/permission/permission.guard';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@UseGuards(CombinedAuthGuard)
|
@UseGuards(CombinedAuthGuard)
|
||||||
@@ -92,25 +94,17 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@Permission('user:view')
|
||||||
async findAll(
|
async findAll(
|
||||||
@Request() req,
|
@Request() req,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('limit') limit?: string,
|
@Query('limit') limit?: string,
|
||||||
) {
|
) {
|
||||||
const callerRole = req.user.role;
|
|
||||||
if (
|
|
||||||
callerRole !== UserRole.SUPER_ADMIN &&
|
|
||||||
callerRole !== UserRole.TENANT_ADMIN
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
this.i18nService.getErrorMessage('adminOnlyViewList'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const p = page ? parseInt(page) : undefined;
|
const p = page ? parseInt(page) : undefined;
|
||||||
const l = limit ? parseInt(limit) : undefined;
|
const l = limit ? parseInt(limit) : undefined;
|
||||||
|
|
||||||
if (callerRole === UserRole.SUPER_ADMIN) {
|
if (req.user.role === UserRole.SUPER_ADMIN) {
|
||||||
return this.userService.findAll(p, l);
|
return this.userService.findAll(p, l);
|
||||||
} else {
|
} else {
|
||||||
return this.userService.findByTenantId(req.user.tenantId, p, l);
|
return this.userService.findByTenantId(req.user.tenantId, p, l);
|
||||||
@@ -144,17 +138,9 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@Permission('user:create')
|
||||||
async createUser(@Request() req, @Body() body: CreateUserDto) {
|
async createUser(@Request() req, @Body() body: CreateUserDto) {
|
||||||
const callerRole = req.user.role;
|
|
||||||
if (
|
|
||||||
callerRole !== UserRole.SUPER_ADMIN &&
|
|
||||||
callerRole !== UserRole.TENANT_ADMIN
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
this.i18nService.getErrorMessage('adminOnlyCreateUser'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password } = body;
|
const { username, password } = body;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
@@ -169,16 +155,9 @@ export class UserController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All new global users default to non-admin.
|
// All new users default to non-admin.
|
||||||
// Elevation to Super Admin status is handled separately.
|
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
|
||||||
if (callerRole === UserRole.SUPER_ADMIN) {
|
|
||||||
isAdmin = false;
|
|
||||||
} else if (callerRole === UserRole.TENANT_ADMIN) {
|
|
||||||
isAdmin = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass the calculated params to the service
|
// Pass the calculated params to the service
|
||||||
return this.userService.createUser(
|
return this.userService.createUser(
|
||||||
username,
|
username,
|
||||||
@@ -190,20 +169,14 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@Permission('user:edit')
|
||||||
async updateUser(
|
async updateUser(
|
||||||
@Request() req,
|
@Request() req,
|
||||||
@Body() body: UpdateUserDto,
|
@Body() body: UpdateUserDto,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
) {
|
) {
|
||||||
const callerRole = req.user.role;
|
const callerRole = req.user.role;
|
||||||
if (
|
|
||||||
callerRole !== UserRole.SUPER_ADMIN &&
|
|
||||||
callerRole !== UserRole.TENANT_ADMIN
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
this.i18nService.getErrorMessage('adminOnlyUpdateUser'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user info to update
|
// Get user info to update
|
||||||
const userToUpdate = await this.userService.findOneById(id);
|
const userToUpdate = await this.userService.findOneById(id);
|
||||||
@@ -228,7 +201,6 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Role modification is now obsolete on global level.
|
// Role modification is now obsolete on global level.
|
||||||
// If Admin wants to elevate, they set isAdmin property directly.
|
|
||||||
if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) {
|
if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) {
|
||||||
if (callerRole !== UserRole.SUPER_ADMIN) {
|
if (callerRole !== UserRole.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -248,16 +220,10 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@Permission('user:delete')
|
||||||
async deleteUser(@Request() req, @Param('id') id: string) {
|
async deleteUser(@Request() req, @Param('id') id: string) {
|
||||||
const callerRole = req.user.role;
|
const callerRole = req.user.role;
|
||||||
if (
|
|
||||||
callerRole !== UserRole.SUPER_ADMIN &&
|
|
||||||
callerRole !== UserRole.TENANT_ADMIN
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
this.i18nService.getErrorMessage('adminOnlyDeleteUser'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent admin from deleting themselves
|
// Prevent admin from deleting themselves
|
||||||
if (req.user.id === id) {
|
if (req.user.id === id) {
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { ApiKey } from '../auth/entities/api-key.entity';
|
|||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { PermissionModule } from '../auth/permission/permission.module';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([User, ApiKey, TenantMember, UserSetting]),
|
TypeOrmModule.forFeature([User, ApiKey, TenantMember, UserSetting]),
|
||||||
TenantModule,
|
TenantModule,
|
||||||
|
PermissionModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService, UserSettingService],
|
providers: [UserService, UserSettingService],
|
||||||
|
|||||||
+113
-38
@@ -1,7 +1,18 @@
|
|||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
const BASE = 'http://localhost:13001';
|
const BASE = 'http://localhost:13001';
|
||||||
const SA_REPLIES = ['需要审查代码质量和安全性', '还要检查逻辑正确性和性能问题'];
|
const SA_REPLIES = [
|
||||||
|
'需要审查代码质量和安全性', // #1 代码审查
|
||||||
|
'还要检查逻辑正确性和性能问题', // #2 代码质量
|
||||||
|
'用清晰的提示词告诉AI具体需求', // #3 prompt技巧
|
||||||
|
'要注意数据安全和隐私保护', // #4 安全
|
||||||
|
'AI协作时要明确分工,人做决策', // #5 协作
|
||||||
|
'检查AI生成的内容是否准确', // #6 验证输出
|
||||||
|
'要测试边界情况和异常处理', // #7 测试
|
||||||
|
'把大任务拆成小步骤和AI沟通', // #8 任务拆分
|
||||||
|
'持续学习和更新对AI工具的认识', // #9 学习成长
|
||||||
|
'评估成本效益,选最合适的方案', // #10 综合
|
||||||
|
];
|
||||||
|
|
||||||
/** Fill a textarea via native setter + input event (reliable for React controlled inputs) */
|
/** Fill a textarea via native setter + input event (reliable for React controlled inputs) */
|
||||||
async function fillTextarea(page, text) {
|
async function fillTextarea(page, text) {
|
||||||
@@ -24,14 +35,19 @@ async function clickSendButton(page) {
|
|||||||
}, { timeout: 15000 });
|
}, { timeout: 15000 });
|
||||||
await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 });
|
await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 });
|
||||||
} catch {
|
} catch {
|
||||||
// Regular click failed (stale/detached element); wait briefly and force
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
await page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {});
|
await page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wait for spinner to disappear */
|
||||||
|
async function waitForIdle(page) {
|
||||||
|
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 90000 }).catch(() => {});
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
console.log('=== AuraK 考核多轮对话测试 ===\n');
|
console.log('=== AuraK 10题考核多轮对话测试 ===\n');
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
|
||||||
@@ -62,40 +78,67 @@ async function run() {
|
|||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
}
|
}
|
||||||
console.log(' ✅ 第 1 题已出现');
|
console.log(' ✅ 第 1 题已出现');
|
||||||
|
await waitForIdle(page);
|
||||||
// Wait spinner to finish
|
|
||||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
|
|
||||||
// Answer questions
|
// Answer questions
|
||||||
let qIdx = 1;
|
let qIdx = 1;
|
||||||
let saCount = 0, followUpCount = 0;
|
let saCount = 0, followUpCount = 0;
|
||||||
const totalQs = 4;
|
const totalQs = 10;
|
||||||
|
const startTime = Date.now();
|
||||||
|
// Per-question timeout: 5 minutes (AI generation can be slow)
|
||||||
|
const Q_TIMEOUT = 300; // 5 min in seconds
|
||||||
|
|
||||||
while (qIdx <= totalQs) {
|
while (qIdx <= totalQs) {
|
||||||
|
// Detect question type
|
||||||
const state = await page.evaluate(() => {
|
const state = await page.evaluate(() => {
|
||||||
const choiceBtns = Array.from(document.querySelectorAll('button'))
|
const allBtns = Array.from(document.querySelectorAll('button'));
|
||||||
.filter(b => /^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5)
|
const optionBtns = allBtns.filter(b =>
|
||||||
.filter(b => !(b.textContent || '').startsWith('AuraK'))
|
/^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5 &&
|
||||||
.filter(b => !(b.textContent || '').startsWith('Admin'));
|
!(b.textContent || '').includes('AuraK') && !(b.textContent || '').includes('Admin')
|
||||||
|
);
|
||||||
|
const confirmBtn = allBtns.find(b => (b.textContent || '').includes('确认答案'));
|
||||||
const ta = document.querySelector('textarea');
|
const ta = document.querySelector('textarea');
|
||||||
return {
|
return {
|
||||||
choiceCount: choiceBtns.length,
|
choiceCount: optionBtns.length,
|
||||||
hasTextarea: ta !== null && ta.offsetParent !== null,
|
hasTextarea: ta !== null && ta.offsetParent !== null,
|
||||||
firstChoice: choiceBtns[0]?.textContent || '',
|
firstChoice: optionBtns[0]?.textContent?.trim().substring(0, 40) || '',
|
||||||
|
hasConfirmBtn: confirmBtn !== null,
|
||||||
busy: document.querySelector('.animate-spin') !== null,
|
busy: document.querySelector('.animate-spin') !== null,
|
||||||
|
hasQuestion: (document.body.textContent || '').includes('问题 ') || (document.body.textContent || '').includes('Question '),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If busy (spinner visible), wait and retry
|
||||||
if (state.busy) {
|
if (state.busy) {
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
console.log(` ⏳ AI正在处理...`);
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If neither question type detected but question text exists, wait more
|
||||||
|
if (state.choiceCount === 0 && !state.hasTextarea && state.hasQuestion) {
|
||||||
|
console.log(` ⏳ 题型未就绪,等待...`);
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current question text
|
||||||
|
const questionText = await page.evaluate(() => {
|
||||||
|
const bubbles = Array.from(document.querySelectorAll('.px-5.py-4'));
|
||||||
|
for (let i = bubbles.length - 1; i >= 0; i--) {
|
||||||
|
const el = bubbles[i];
|
||||||
|
const text = el.textContent || '';
|
||||||
|
if (text.length > 25 && !(el.getAttribute('class') || '').includes('bg-indigo')) {
|
||||||
|
return text.substring(0, 160);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
if (state.choiceCount > 0) {
|
if (state.choiceCount > 0) {
|
||||||
// ── CHOICE ──
|
// ── CHOICE ──
|
||||||
console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${state.firstChoice.substring(0, 30)}..."`);
|
const qShort = questionText.replace(/\s+/g, ' ').substring(0, 100);
|
||||||
|
console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${qShort}..."`);
|
||||||
|
|
||||||
const btns = page.locator('button.w-full.text-left.px-5.py-4');
|
const btns = page.locator('button.w-full.text-left.px-5.py-4');
|
||||||
const count = await btns.count();
|
const count = await btns.count();
|
||||||
@@ -104,12 +147,14 @@ async function run() {
|
|||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirm = page.locator('button:has-text("确认答案")');
|
if (state.hasConfirmBtn) {
|
||||||
if (await confirm.isVisible().catch(() => false)) {
|
await page.locator('button:has-text("确认答案")').click();
|
||||||
await confirm.click();
|
|
||||||
console.log(` ✅ 已提交`);
|
console.log(` ✅ 已提交`);
|
||||||
}
|
}
|
||||||
qIdx++;
|
qIdx++;
|
||||||
|
// After submitting a choice, wait for transition
|
||||||
|
await waitForIdle(page);
|
||||||
|
|
||||||
} else if (state.hasTextarea) {
|
} else if (state.hasTextarea) {
|
||||||
// ── SHORT ANSWER ──
|
// ── SHORT ANSWER ──
|
||||||
saCount++;
|
saCount++;
|
||||||
@@ -123,17 +168,15 @@ async function run() {
|
|||||||
console.log(` ✅ 已提交`);
|
console.log(` ✅ 已提交`);
|
||||||
|
|
||||||
// Wait for grading
|
// Wait for grading
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
await waitForIdle(page);
|
||||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
|
|
||||||
// Check for follow-up: textarea visible again at same question position
|
// Check for follow-up question
|
||||||
const stillTA = await page.evaluate(() => {
|
const stillTA = await page.evaluate(() => {
|
||||||
const ta = document.querySelector('textarea');
|
const ta = document.querySelector('textarea');
|
||||||
return ta !== null && ta.offsetParent !== null;
|
return ta !== null && ta.offsetParent !== null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stillTA && followUpCount < SA_REPLIES.length - 1) {
|
if (stillTA) {
|
||||||
followUpCount++;
|
followUpCount++;
|
||||||
const fReply = SA_REPLIES[Math.min(followUpCount, SA_REPLIES.length - 1)];
|
const fReply = SA_REPLIES[Math.min(followUpCount, SA_REPLIES.length - 1)];
|
||||||
console.log(` 🔄 AI 追问 #${followUpCount} 已触发!`);
|
console.log(` 🔄 AI 追问 #${followUpCount} 已触发!`);
|
||||||
@@ -144,33 +187,62 @@ async function run() {
|
|||||||
await clickSendButton(page);
|
await clickSendButton(page);
|
||||||
console.log(` ✅ 追问已提交`);
|
console.log(` ✅ 追问已提交`);
|
||||||
|
|
||||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
await waitForIdle(page);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
qIdx++;
|
qIdx++;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Wait for anything to appear
|
// ── WAITING for question to appear ──
|
||||||
console.log(` ⏳ 等待第 ${qIdx} 题...`);
|
// Check for question text in body
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
const bodyText = await page.textContent('body').catch(() => '');
|
||||||
|
if (bodyText.includes('问题 ') || bodyText.includes('Question ')) {
|
||||||
|
// Question is there but types not detected yet - wait for spinner then retry
|
||||||
|
console.log(` ⏳ 第 ${qIdx} 题文本已见,等待组件渲染...`);
|
||||||
|
await waitForIdle(page);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if assessment completed
|
||||||
|
if (bodyText.includes('合格') || bodyText.includes('VERIFIED') || bodyText.includes('LEVEL')) {
|
||||||
|
console.log(`\n 📋 考核已完成!`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-question timeout
|
||||||
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
if (elapsed > Q_TIMEOUT * qIdx + 120) {
|
||||||
|
console.log(` ⏰ 第 ${qIdx} 题生成超时,跳过`);
|
||||||
|
qIdx++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ⏳ 等待 AI 生成第 ${qIdx} 题...`);
|
||||||
|
await waitForIdle(page);
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for next question
|
|
||||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 30000 }).catch(() => {});
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Results
|
// Wait for results page
|
||||||
await new Promise(r => setTimeout(r, 4000));
|
console.log('\n ⏳ 等待考核结果完成...');
|
||||||
|
await waitForIdle(page);
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
|
||||||
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||||
const body = await page.textContent('body');
|
const body = await page.textContent('body');
|
||||||
const scores = body.match(/\d+\/10/g);
|
const scores = body.match(/\d+\/10/g);
|
||||||
|
const level = body.match(/LEVEL:\s*(\w+)/i)?.[1] || body.match(/等级[::]\s*(\w+)/)?.[1] || '?';
|
||||||
|
const passed = body.includes('合格') || body.includes('VERIFIED');
|
||||||
|
|
||||||
console.log(`\n 📊 结果:`);
|
console.log(`\n 📊 结果 (耗时 ${Math.floor(elapsed/60)}分${elapsed%60}秒):`);
|
||||||
|
console.log(` 总题数: ${totalQs}`);
|
||||||
console.log(` 选择题: ${totalQs - saCount}`);
|
console.log(` 选择题: ${totalQs - saCount}`);
|
||||||
console.log(` 简答题: ${saCount}`);
|
console.log(` 简答题: ${saCount}`);
|
||||||
console.log(` AI追问: ${followUpCount}`);
|
console.log(` AI追问: ${followUpCount}次`);
|
||||||
console.log(` 分数: ${scores ? scores.join(', ') : '无'}`);
|
console.log(` 分数: ${scores ? scores.join(', ') : '无'}`);
|
||||||
|
console.log(` 等级: ${level}`);
|
||||||
|
console.log(` ${passed ? '🎉 合格!' : '😅 未合格'}`);
|
||||||
|
|
||||||
if (followUpCount > 0) {
|
if (followUpCount > 0) {
|
||||||
console.log(`\n 🎉 多轮对话正常工作!`);
|
console.log(`\n 🎉 多轮对话正常工作!`);
|
||||||
@@ -180,6 +252,9 @@ async function run() {
|
|||||||
console.log(`\n ⚠️ 未遇到简答题,需要确认 shuffle 是否生效。`);
|
console.log(`\n ⚠️ 未遇到简答题,需要确认 shuffle 是否生效。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'assessment-10q-result.png', fullPage: true });
|
||||||
|
console.log(' 📸 截图已保存');
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
console.log('\n=== 完成 ===');
|
console.log('\n=== 完成 ===');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePermissions } from '../src/hooks/usePermissions';
|
||||||
|
|
||||||
|
interface PermissionGateProps {
|
||||||
|
/** 需要的权限(OR 关系:有任一即可) */
|
||||||
|
permission?: string;
|
||||||
|
/** 多个权限 OR 关系 */
|
||||||
|
any?: string[];
|
||||||
|
/** 多个权限 AND 关系(必须全部拥有) */
|
||||||
|
all?: string[];
|
||||||
|
/** 加载中时显示的内容(默认不显示) */
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
/** 无权限时显示的内容(默认不显示) */
|
||||||
|
denied?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件级权限门控
|
||||||
|
* 根据用户权限集有条件的渲染子组件
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PermissionGate permission="user:create">
|
||||||
|
* <Button>创建用户</Button>
|
||||||
|
* </PermissionGate>
|
||||||
|
*
|
||||||
|
* <PermissionGate any={['user:edit', 'user:delete']}>
|
||||||
|
* <AdminPanel />
|
||||||
|
* </PermissionGate>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const PermissionGate: React.FC<PermissionGateProps> = ({
|
||||||
|
permission,
|
||||||
|
any,
|
||||||
|
all,
|
||||||
|
fallback = null,
|
||||||
|
denied = null,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { hasPermission, hasAnyPermission, hasAllPermissions, isLoading } = usePermissions();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let granted = false;
|
||||||
|
|
||||||
|
if (permission) {
|
||||||
|
granted = hasPermission(permission);
|
||||||
|
} else if (any && any.length > 0) {
|
||||||
|
granted = hasAnyPermission(...any);
|
||||||
|
} else if (all && all.length > 0) {
|
||||||
|
granted = hasAllPermissions(...all);
|
||||||
|
} else {
|
||||||
|
// 没有指定权限要求 → 放行
|
||||||
|
granted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{granted ? children : denied}</>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
Users,
|
||||||
|
Key,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '../../src/contexts/AuthContext';
|
||||||
|
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
import { cn } from '../../src/utils/cn';
|
||||||
|
|
||||||
|
interface PermissionMeta {
|
||||||
|
key: string;
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
baseRole?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按分类分组的权限 */
|
||||||
|
interface PermissionsByCategory {
|
||||||
|
[category: string]: PermissionMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PermissionSettingsView: React.FC = () => {
|
||||||
|
const { apiKey, activeTenant } = useAuth();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
const { showError, showSuccess } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [allPermissions, setAllPermissions] = useState<PermissionsByCategory>({});
|
||||||
|
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||||
|
const [rolePermissions, setRolePermissions] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoadingRoles, setIsLoadingRoles] = useState(true);
|
||||||
|
const [isLoadingPerms, setIsLoadingPerms] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||||
|
const [newRoleName, setNewRoleName] = useState('');
|
||||||
|
const [newRoleDesc, setNewRoleDesc] = useState('');
|
||||||
|
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(new Set());
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-tenant-id': activeTenant?.tenantId || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载角色列表
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingRoles(true);
|
||||||
|
const res = await fetch('/api/roles', { headers });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setRoles(data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch roles:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRoles(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载权限元数据
|
||||||
|
const fetchPermissions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/permissions', { headers });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAllPermissions(data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch permissions:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载选中角色的权限
|
||||||
|
const fetchRolePermissions = async (roleId: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoadingPerms(true);
|
||||||
|
const res = await fetch(`/api/roles/${roleId}/permissions`, { headers });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const permSet = new Set<string>(data.permissions || []);
|
||||||
|
setRolePermissions(permSet);
|
||||||
|
setEditingPermissions(new Set(permSet));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch role permissions:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingPerms(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [apiKey, activeTenant]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRoleId) {
|
||||||
|
fetchRolePermissions(selectedRoleId);
|
||||||
|
}
|
||||||
|
}, [selectedRoleId]);
|
||||||
|
|
||||||
|
// 切换权限
|
||||||
|
const togglePermission = (key: string) => {
|
||||||
|
setEditingPermissions(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
setHasChanges(true);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选/取消分类
|
||||||
|
const toggleCategory = (category: string, perms: PermissionMeta[]) => {
|
||||||
|
const keys = perms.map(p => p.key);
|
||||||
|
const allChecked = keys.every(k => editingPermissions.has(k));
|
||||||
|
setEditingPermissions(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
keys.forEach(k => {
|
||||||
|
if (allChecked) next.delete(k);
|
||||||
|
else next.add(k);
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存权限
|
||||||
|
const savePermissions = async () => {
|
||||||
|
if (!selectedRoleId) return;
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const res = await fetch(`/api/roles/${selectedRoleId}/permissions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ permissions: [...editingPermissions] }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setRolePermissions(new Set(editingPermissions));
|
||||||
|
setHasChanges(false);
|
||||||
|
showSuccess?.('权限已保存');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.message || '保存失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showError?.(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建角色
|
||||||
|
const createRole = async () => {
|
||||||
|
if (!newRoleName.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newRoleName.trim(), description: newRoleDesc.trim() }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await fetchRoles();
|
||||||
|
setShowCreateRole(false);
|
||||||
|
setNewRoleName('');
|
||||||
|
setNewRoleDesc('');
|
||||||
|
showSuccess?.('角色已创建');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.message || '创建失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showError?.(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除角色
|
||||||
|
const deleteRole = async (role: Role) => {
|
||||||
|
const confirmed = await confirm?.(`确定删除角色"${role.name}"?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE', headers });
|
||||||
|
if (res.ok) {
|
||||||
|
if (selectedRoleId === role.id) setSelectedRoleId(null);
|
||||||
|
await fetchRoles();
|
||||||
|
showSuccess?.('角色已删除');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showError?.(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRole = roles.find(r => r.id === selectedRoleId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-6">
|
||||||
|
{/* 左:角色列表 */}
|
||||||
|
<div className="w-72 flex-none space-y-3">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-black text-slate-900 flex items-center gap-2 uppercase tracking-widest">
|
||||||
|
<Shield size={16} className="text-indigo-600" />
|
||||||
|
角色
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateRole(true)}
|
||||||
|
className="p-1.5 rounded-lg bg-indigo-50 text-indigo-600 hover:bg-indigo-100 transition-colors"
|
||||||
|
title="新建角色"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingRoles ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 size={20} className="animate-spin text-slate-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{roles.map(role => (
|
||||||
|
<button
|
||||||
|
key={role.id}
|
||||||
|
onClick={() => setSelectedRoleId(role.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all flex items-center justify-between',
|
||||||
|
selectedRoleId === role.id
|
||||||
|
? 'bg-indigo-50 text-indigo-700 border border-indigo-200 shadow-sm'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50 border border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="truncate">{role.name}</span>
|
||||||
|
{role.isSystem && (
|
||||||
|
<span className="text-[9px] font-black text-indigo-400 bg-indigo-100/50 px-1.5 py-0.5 rounded uppercase tracking-wider shrink-0">
|
||||||
|
系统
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!role.isSystem && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteRole(role); }}
|
||||||
|
className="p-1 text-slate-300 hover:text-rose-500 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建角色弹窗 */}
|
||||||
|
{showCreateRole && (
|
||||||
|
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200 space-y-3">
|
||||||
|
<input
|
||||||
|
value={newRoleName}
|
||||||
|
onChange={e => setNewRoleName(e.target.value)}
|
||||||
|
placeholder="角色名称"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={newRoleDesc}
|
||||||
|
onChange={e => setNewRoleDesc(e.target.value)}
|
||||||
|
placeholder="角色描述(可选)"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={createRole}
|
||||||
|
disabled={!newRoleName.trim()}
|
||||||
|
className="flex-1 py-2 bg-indigo-600 text-white text-xs font-bold rounded-lg hover:bg-indigo-700 disabled:bg-slate-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateRole(false)}
|
||||||
|
className="px-3 py-2 text-xs font-bold text-slate-500 hover:text-slate-700 bg-white border border-slate-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:权限矩阵 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{!selectedRole ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
|
||||||
|
<Shield size={40} className="opacity-30" />
|
||||||
|
<p className="text-sm font-bold">选择一个角色查看权限</p>
|
||||||
|
</div>
|
||||||
|
) : isLoadingPerms ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 size={24} className="animate-spin text-slate-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 角色标题 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-black text-slate-900 flex items-center gap-2">
|
||||||
|
<Key size={18} className="text-indigo-600" />
|
||||||
|
{selectedRole.name}
|
||||||
|
{selectedRole.isSystem && (
|
||||||
|
<span className="text-[10px] font-black text-slate-400 bg-slate-100 px-2 py-0.5 rounded-full uppercase tracking-wider">
|
||||||
|
系统角色
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{selectedRole.description && (
|
||||||
|
<p className="text-xs text-slate-500 mt-1">{selectedRole.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingPermissions(new Set(rolePermissions));
|
||||||
|
setHasChanges(false);
|
||||||
|
}}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
className="px-3 py-2 text-xs font-bold text-slate-500 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={savePermissions}
|
||||||
|
disabled={!hasChanges || isSaving || selectedRole.isSystem}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all',
|
||||||
|
hasChanges && !selectedRole.isSystem
|
||||||
|
? 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm'
|
||||||
|
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={14} />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRole.isSystem && (
|
||||||
|
<div className="px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-700 font-medium">
|
||||||
|
系统角色的权限不可修改。可以创建自定义角色,然后分配所需权限。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 权限矩阵 */}
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{Object.entries(allPermissions).map(([category, perms]) => {
|
||||||
|
const allChecked = perms.every(p => editingPermissions.has(p.key));
|
||||||
|
const someChecked = perms.some(p => editingPermissions.has(p.key));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category} className="bg-white border border-slate-100 rounded-2xl overflow-hidden">
|
||||||
|
{/* 分类标题 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCategory(category, perms)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3 bg-slate-50/80 hover:bg-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-black text-slate-700 uppercase tracking-wider">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-bold px-2 py-0.5 rounded-full',
|
||||||
|
allChecked
|
||||||
|
? 'bg-indigo-100 text-indigo-600'
|
||||||
|
: someChecked
|
||||||
|
? 'bg-amber-100 text-amber-600'
|
||||||
|
: 'bg-slate-100 text-slate-400',
|
||||||
|
)}>
|
||||||
|
{perms.filter(p => editingPermissions.has(p.key)).length}/{perms.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 权限列表 */}
|
||||||
|
<div className="divide-y divide-slate-50">
|
||||||
|
{perms.map(perm => (
|
||||||
|
<label
|
||||||
|
key={perm.key}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-5 py-2.5 cursor-pointer transition-colors hover:bg-slate-50',
|
||||||
|
!selectedRole.isSystem ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingPermissions.has(perm.key)}
|
||||||
|
onChange={() => togglePermission(perm.key)}
|
||||||
|
disabled={selectedRole.isSystem}
|
||||||
|
className="w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-bold text-slate-800">{perm.label}</div>
|
||||||
|
<div className="text-xs text-slate-400">{perm.description}</div>
|
||||||
|
</div>
|
||||||
|
<code className="text-[10px] text-slate-300 font-mono shrink-0">{perm.key}</code>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -52,6 +52,7 @@ import { userSettingService } from '../../services/userSettingService';
|
|||||||
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
|
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
|
||||||
import { apiClient } from '../../services/apiClient';
|
import { apiClient } from '../../services/apiClient';
|
||||||
import { AssessmentTemplateManager } from './AssessmentTemplateManager';
|
import { AssessmentTemplateManager } from './AssessmentTemplateManager';
|
||||||
|
import { PermissionSettingsView } from './PermissionSettingsView';
|
||||||
|
|
||||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
@@ -66,7 +67,7 @@ interface SettingsViewProps {
|
|||||||
initialTab?: TabType;
|
initialTab?: TabType;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates';
|
type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates' | 'permissions';
|
||||||
|
|
||||||
const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
|
const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
|
||||||
const map = new Map<string, Tenant>();
|
const map = new Map<string, Tenant>();
|
||||||
@@ -2131,6 +2132,16 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
{t('assessmentTemplates')}
|
{t('assessmentTemplates')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('permissions')}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'permissions' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield size={18} />
|
||||||
|
权限管理
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2139,10 +2150,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
|
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
|
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
|
||||||
{activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : t('assessmentTemplates')}
|
{activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : activeTab === 'permissions' ? '权限管理' : t('assessmentTemplates')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[15px] text-slate-500 mt-1">
|
<p className="text-[15px] text-slate-500 mt-1">
|
||||||
{activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : t('assessmentTemplatesSubtitle')}
|
{activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : activeTab === 'permissions' ? '管理角色和细粒度权限' : t('assessmentTemplatesSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2183,6 +2194,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
<AssessmentTemplateManager />
|
<AssessmentTemplateManager />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'permissions' && isAdmin && (
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar" style={{ height: 'calc(100vh - 220px)' }}>
|
||||||
|
<PermissionSettingsView />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端权限 hook
|
||||||
|
* 获取当前用户在活动租户下的权限集,提供便捷的检查方法
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { hasPermission, hasAnyPermission, isLoading } = usePermissions();
|
||||||
|
*
|
||||||
|
* if (hasPermission('user:create')) {
|
||||||
|
* // 渲染创建用户按钮
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* {hasAnyPermission('user:edit', 'user:delete') && <AdminActions />}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function usePermissions() {
|
||||||
|
const { apiKey, activeTenant } = useAuth();
|
||||||
|
const [permissions, setPermissions] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
if (!apiKey || !activeTenant) {
|
||||||
|
setPermissions([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await fetch('/api/permissions/mine', {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-tenant-id': activeTenant.tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPermissions(data.permissions || []);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setPermissions([]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch permissions:', err);
|
||||||
|
setError(err.message);
|
||||||
|
setPermissions([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiKey, activeTenant?.tenantId]);
|
||||||
|
|
||||||
|
// 获取权限
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(key: string) => permissions.includes(key),
|
||||||
|
[permissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyPermission = useCallback(
|
||||||
|
(...keys: string[]) => keys.some(k => permissions.includes(k)),
|
||||||
|
[permissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAllPermissions = useCallback(
|
||||||
|
(...keys: string[]) => keys.every(k => permissions.includes(k)),
|
||||||
|
[permissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
refresh: fetchPermissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user