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:
Developer
2026-06-08 23:25:22 +08:00
parent c57c3028e2
commit ba33d517c1
17 changed files with 1386 additions and 87 deletions
+2
View File
@@ -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 adminisAdmin=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);
}
}
}
+49
View File
@@ -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;
}
+12 -46
View File
@@ -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) {
+2
View File
@@ -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
View File
@@ -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=== 完成 ===');
} }
+61
View File
@@ -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>
);
};
+19 -3
View File
@@ -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>
+86
View File
@@ -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,
};
}