From 489d7232e3cfde087fa62a1c4141780b8f91cd89 Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 12:19:42 +0800 Subject: [PATCH] feat: user list search + current-user menu tree endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /auth/menus:当前用户菜单树(超管全量,非超管按角色 + 补祖先) - 用户列表支持 username/nickname/phone/status 过滤(ListUserQuery) - seed:菜单补图标、新增「权限管理」顶级分组(用户/角色/资源/组织),dashboard path 修正为 / Co-Authored-By: Claude Opus 4.8 --- prisma/seed.ts | 28 +++++----- src/auth/auth.controller.ts | 11 +++- src/auth/auth.service.ts | 96 +++++++++++++++++++++++++++++++++++ src/users/dto.ts | 23 +++++++++ src/users/users.controller.ts | 5 +- src/users/users.service.ts | 9 ++-- 6 files changed, 152 insertions(+), 20 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 7e9955a..f1174bf 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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 { 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 { type: 'menu', path: m.path, component: m.component, + icon: m.icon, sort: m.sort, status: 'enabled', }, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c293582..255fe4f 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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); + } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bac3911..fda08b6 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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(); + 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 { + 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 { + 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 { const u = await this.prisma.client.user.findFirst({ diff --git a/src/users/dto.ts b/src/users/dto.ts index f455d8e..2f1b664 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -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; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index a5eadb6..635896d 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -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); } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 1fb11cf..fdde430 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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 = {}; + 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 };