feat: user list search + current-user menu tree endpoint
- GET /auth/menus:当前用户菜单树(超管全量,非超管按角色 + 补祖先) - 用户列表支持 username/nickname/phone/status 过滤(ListUserQuery) - seed:菜单补图标、新增「权限管理」顶级分组(用户/角色/资源/组织),dashboard path 修正为 / Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ce2aa35a24
commit
489d7232e3
@ -11,18 +11,19 @@ const ADMIN_PASSWORD = 'Touyun@123';
|
||||
|
||||
/** 菜单资源(导航)。 */
|
||||
const MENUS = [
|
||||
{ code: 'dashboard', name: '工作台', parent: null, path: '/dashboard', component: 'dashboard/index', sort: 1 },
|
||||
{ code: 'system', name: '系统管理', parent: null, path: '/system', component: 'Layout', sort: 2 },
|
||||
{ code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', sort: 1 },
|
||||
{ code: 'user', name: '用户管理', parent: 'system', path: '/system/user', component: 'system/user/index', sort: 2 },
|
||||
{ code: 'role', name: '角色管理', parent: 'system', path: '/system/role', component: 'system/role/index', sort: 3 },
|
||||
{ code: 'resource', name: '资源管理', parent: 'system', path: '/system/resource', component: 'system/resource/index', sort: 4 },
|
||||
{ code: 'org', name: '组织管理', parent: 'system', path: '/system/org', component: 'system/org/index', sort: 5 },
|
||||
{ code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', sort: 6 },
|
||||
{ code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', sort: 7 },
|
||||
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', sort: 3 },
|
||||
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', sort: 1 },
|
||||
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', sort: 2 },
|
||||
{ code: 'dashboard', name: '工作台', parent: null, path: '/', component: 'dashboard/index', icon: 'mdi:view-dashboard', sort: 1 },
|
||||
{ code: 'permission', name: '权限管理', parent: null, path: '/permission', component: 'Layout', icon: 'mdi:shield-account', sort: 2 },
|
||||
{ code: 'user', name: '用户管理', parent: 'permission', path: '/system/user', component: 'system/user/index', icon: 'mdi:account-group', sort: 1 },
|
||||
{ code: 'role', name: '角色管理', parent: 'permission', path: '/system/role', component: 'system/role/index', icon: 'mdi:account-key', sort: 2 },
|
||||
{ code: 'resource', name: '资源管理', parent: 'permission', path: '/system/resource', component: 'system/resource/index', icon: 'mdi:view-list', sort: 3 },
|
||||
{ code: 'org', name: '组织管理', parent: 'permission', path: '/system/org', component: 'system/org/index', icon: 'mdi:sitemap', sort: 4 },
|
||||
{ code: 'system', name: '系统管理', parent: null, path: '/system', component: 'Layout', icon: 'mdi:cog', sort: 3 },
|
||||
{ code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', icon: 'mdi:domain', sort: 1 },
|
||||
{ code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', icon: 'mdi:book-open-variant', sort: 2 },
|
||||
{ code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', icon: 'mdi:tune', sort: 3 },
|
||||
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
|
||||
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
|
||||
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
|
||||
];
|
||||
|
||||
/** 接口资源(每个受控接口一个,code = 权限码)。 */
|
||||
@ -119,7 +120,7 @@ async function main(): Promise<void> {
|
||||
allResourceCodes.push(m.code);
|
||||
await prisma.resource.upsert({
|
||||
where: { tenantCode_code: { tenantCode: TENANT, code: m.code } },
|
||||
update: { name: m.name, parentCode: m.parent, path: m.path, component: m.component, sort: m.sort, type: 'menu' },
|
||||
update: { name: m.name, parentCode: m.parent, path: m.path, component: m.component, icon: m.icon, sort: m.sort, type: 'menu' },
|
||||
create: {
|
||||
tenantCode: TENANT,
|
||||
code: m.code,
|
||||
@ -128,6 +129,7 @@ async function main(): Promise<void> {
|
||||
type: 'menu',
|
||||
path: m.path,
|
||||
component: m.component,
|
||||
icon: m.icon,
|
||||
sort: m.sort,
|
||||
status: 'enabled',
|
||||
},
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Post, Req } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post, Req, UnauthorizedException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto, RefreshDto } from './dto.js';
|
||||
import { Public } from './decorators.js';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@ -25,4 +26,12 @@ export class AuthController {
|
||||
logout(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
return this.auth.logout(dto, req.ip, req.headers['user-agent']);
|
||||
}
|
||||
|
||||
/** 当前用户菜单树(仅需登录,无需额外权限)。 */
|
||||
@Get('menus')
|
||||
menus() {
|
||||
const ctx = getRequestContext();
|
||||
if (!ctx) throw new UnauthorizedException('未登录');
|
||||
return this.auth.getMenus(ctx.tenantCode, ctx.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,52 @@ export interface PermissionSet {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface MenuRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parentCode: string | null;
|
||||
type: string;
|
||||
path: string | null;
|
||||
component: string | null;
|
||||
icon: string | null;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export interface MenuNode extends MenuRow {
|
||||
children: MenuNode[];
|
||||
}
|
||||
|
||||
const MENU_SELECT = {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
parentCode: true,
|
||||
type: true,
|
||||
path: true,
|
||||
component: true,
|
||||
icon: true,
|
||||
sort: true,
|
||||
} as const;
|
||||
|
||||
/** 由扁平菜单行构建树(按 sort + code 升序)。 */
|
||||
function buildMenuTree(rows: MenuRow[]): MenuNode[] {
|
||||
const map = new Map<string, MenuNode>();
|
||||
for (const r of rows) map.set(r.code, { ...r, children: [] });
|
||||
const roots: MenuNode[] = [];
|
||||
for (const node of map.values()) {
|
||||
const parent = node.parentCode ? map.get(node.parentCode) : undefined;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
const sortRec = (nodes: MenuNode[]): void => {
|
||||
nodes.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code));
|
||||
for (const n of nodes) sortRec(n.children);
|
||||
};
|
||||
sortRec(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@ -120,6 +166,56 @@ export class AuthService {
|
||||
return this.writePermissionCache(tenantCode, userId);
|
||||
}
|
||||
|
||||
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
|
||||
async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> {
|
||||
const roleRows = await this.prisma.client.userRole.findMany({
|
||||
where: { tenantCode, userId },
|
||||
select: { roleCode: true },
|
||||
});
|
||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
||||
const isSuper = roleCodes.includes(SUPER_ROLE);
|
||||
|
||||
const rows = isSuper
|
||||
? await this.prisma.client.resource.findMany({
|
||||
where: { tenantCode, type: 'menu', status: 'enabled', deletedAt: null },
|
||||
orderBy: { sort: 'asc' },
|
||||
select: MENU_SELECT,
|
||||
})
|
||||
: await this.menusForRoles(tenantCode, roleCodes);
|
||||
|
||||
return buildMenuTree(rows as MenuRow[]);
|
||||
}
|
||||
|
||||
/** 非超管:角色可访问的菜单 + 其祖先(保证目录可达)。 */
|
||||
private async menusForRoles(tenantCode: string, roleCodes: string[]): Promise<MenuRow[]> {
|
||||
if (!roleCodes.length) return [];
|
||||
const roleResourceRows = await this.prisma.client.roleResource.findMany({
|
||||
where: { tenantCode, roleCode: { in: roleCodes } },
|
||||
select: { resourceCode: true },
|
||||
});
|
||||
const codes = [...new Set(roleResourceRows.map((r) => r.resourceCode))];
|
||||
if (!codes.length) return [];
|
||||
|
||||
const accessible = await this.prisma.client.resource.findMany({
|
||||
where: { tenantCode, code: { in: codes }, type: 'menu', status: 'enabled', deletedAt: null },
|
||||
select: MENU_SELECT,
|
||||
});
|
||||
const all = await this.prisma.client.resource.findMany({
|
||||
where: { tenantCode, type: 'menu', deletedAt: null },
|
||||
select: MENU_SELECT,
|
||||
});
|
||||
const byCode = new Map(all.map((r) => [r.code, r]));
|
||||
const needed = new Set(accessible.map((r) => r.code));
|
||||
for (const r of [...accessible]) {
|
||||
let cur = r.parentCode ? byCode.get(r.parentCode) : undefined;
|
||||
while (cur && !needed.has(cur.code)) {
|
||||
needed.add(cur.code);
|
||||
cur = cur.parentCode ? byCode.get(cur.parentCode) : undefined;
|
||||
}
|
||||
}
|
||||
return all.filter((r) => needed.has(r.code)) as MenuRow[];
|
||||
}
|
||||
|
||||
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
|
||||
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
|
||||
const u = await this.prisma.client.user.findFirst({
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ description: '用户名' })
|
||||
@ -76,3 +77,25 @@ export class ResetPasswordDto {
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class ListUserQuery extends PageQuery {
|
||||
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '昵称(模糊)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '手机号(模糊)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { Permissions } from '../common/permissions.js';
|
||||
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||
import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
|
||||
@Controller('users')
|
||||
@ -11,7 +10,7 @@ export class UsersController {
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permissions.user.list)
|
||||
list(@Query() q: PageQuery) {
|
||||
list(@Query() q: ListUserQuery) {
|
||||
return this.service.listUsers(q);
|
||||
}
|
||||
|
||||
|
||||
@ -3,8 +3,7 @@ import bcrypt from 'bcryptjs';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService extends BaseCrudService {
|
||||
@ -19,9 +18,13 @@ export class UsersService extends BaseCrudService {
|
||||
return this.omitPasswordHash(created);
|
||||
}
|
||||
|
||||
async listUsers(q: PageQuery) {
|
||||
async listUsers(q: ListUserQuery) {
|
||||
const ctx = getRequestContext();
|
||||
const where: Record<string, unknown> = {};
|
||||
if (q.username) where.username = { contains: q.username };
|
||||
if (q.nickname) where.nickname = { contains: q.nickname };
|
||||
if (q.phone) where.phone = { contains: q.phone };
|
||||
if (q.status) where.status = q.status;
|
||||
// 数据权限:orgCodes 为空表示「全部可见」,否则限制在可见部门集合
|
||||
if (ctx && ctx.orgCodes.length > 0) {
|
||||
where.orgCode = { in: ctx.orgCodes };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user