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:
liuyun 2026-10-11 12:19:42 +08:00
parent ce2aa35a24
commit 489d7232e3
6 changed files with 152 additions and 20 deletions

View File

@ -11,18 +11,19 @@ const ADMIN_PASSWORD = 'Touyun@123';
/** 菜单资源(导航)。 */ /** 菜单资源(导航)。 */
const MENUS = [ const MENUS = [
{ code: 'dashboard', name: '工作台', parent: null, path: '/dashboard', component: 'dashboard/index', sort: 1 }, { code: 'dashboard', name: '工作台', parent: null, path: '/', component: 'dashboard/index', icon: 'mdi:view-dashboard', sort: 1 },
{ code: 'system', name: '系统管理', parent: null, path: '/system', component: 'Layout', sort: 2 }, { code: 'permission', name: '权限管理', parent: null, path: '/permission', component: 'Layout', icon: 'mdi:shield-account', sort: 2 },
{ code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', sort: 1 }, { code: 'user', name: '用户管理', parent: 'permission', path: '/system/user', component: 'system/user/index', icon: 'mdi:account-group', sort: 1 },
{ code: 'user', name: '用户管理', parent: 'system', path: '/system/user', component: 'system/user/index', sort: 2 }, { code: 'role', name: '角色管理', parent: 'permission', path: '/system/role', component: 'system/role/index', icon: 'mdi:account-key', sort: 2 },
{ code: 'role', name: '角色管理', parent: 'system', path: '/system/role', component: 'system/role/index', sort: 3 }, { code: 'resource', name: '资源管理', parent: 'permission', path: '/system/resource', component: 'system/resource/index', icon: 'mdi:view-list', sort: 3 },
{ code: 'resource', name: '资源管理', parent: 'system', path: '/system/resource', component: 'system/resource/index', sort: 4 }, { code: 'org', name: '组织管理', parent: 'permission', path: '/system/org', component: 'system/org/index', icon: 'mdi:sitemap', sort: 4 },
{ code: 'org', name: '组织管理', parent: 'system', path: '/system/org', component: 'system/org/index', sort: 5 }, { code: 'system', name: '系统管理', parent: null, path: '/system', component: 'Layout', icon: 'mdi:cog', sort: 3 },
{ code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', sort: 6 }, { code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', icon: 'mdi:domain', sort: 1 },
{ code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', sort: 7 }, { code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', icon: 'mdi:book-open-variant', sort: 2 },
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', sort: 3 }, { code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', icon: 'mdi:tune', sort: 3 },
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', sort: 1 }, { code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', sort: 2 }, { 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 = 权限码)。 */ /** 接口资源(每个受控接口一个,code = 权限码)。 */
@ -119,7 +120,7 @@ async function main(): Promise<void> {
allResourceCodes.push(m.code); allResourceCodes.push(m.code);
await prisma.resource.upsert({ await prisma.resource.upsert({
where: { tenantCode_code: { tenantCode: TENANT, code: m.code } }, 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: { create: {
tenantCode: TENANT, tenantCode: TENANT,
code: m.code, code: m.code,
@ -128,6 +129,7 @@ async function main(): Promise<void> {
type: 'menu', type: 'menu',
path: m.path, path: m.path,
component: m.component, component: m.component,
icon: m.icon,
sort: m.sort, sort: m.sort,
status: 'enabled', status: 'enabled',
}, },

View File

@ -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 type { Request } from 'express';
import { AuthService } from './auth.service.js'; import { AuthService } from './auth.service.js';
import { LoginDto, RefreshDto } from './dto.js'; import { LoginDto, RefreshDto } from './dto.js';
import { Public } from './decorators.js'; import { Public } from './decorators.js';
import { getRequestContext } from '../common/context/request-context.js';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -25,4 +26,12 @@ export class AuthController {
logout(@Body() dto: RefreshDto, @Req() req: Request) { logout(@Body() dto: RefreshDto, @Req() req: Request) {
return this.auth.logout(dto, req.ip, req.headers['user-agent']); 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);
}
} }

View File

@ -20,6 +20,52 @@ export interface PermissionSet {
permissions: string[]; 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() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@ -120,6 +166,56 @@ export class AuthService {
return this.writePermissionCache(tenantCode, userId); 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 调用,构建当前请求的租户/用户/数据权限上下文。 */ /** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> { async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
const u = await this.prisma.client.user.findFirst({ const u = await this.prisma.client.user.findFirst({

View File

@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class CreateUserDto { export class CreateUserDto {
@ApiProperty({ description: '用户名' }) @ApiProperty({ description: '用户名' })
@ -76,3 +77,25 @@ export class ResetPasswordDto {
@IsNotEmpty() @IsNotEmpty()
password!: string; 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;
}

View File

@ -1,8 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; 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 { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.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'; import { UsersService } from './users.service.js';
@Controller('users') @Controller('users')
@ -11,7 +10,7 @@ export class UsersController {
@Get() @Get()
@RequirePermissions(Permissions.user.list) @RequirePermissions(Permissions.user.list)
list(@Query() q: PageQuery) { list(@Query() q: ListUserQuery) {
return this.service.listUsers(q); return this.service.listUsers(q);
} }

View File

@ -3,8 +3,7 @@ import bcrypt from 'bcryptjs';
import { BaseCrudService } from '../common/base-crud.service.js'; import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js'; import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js'; import { getRequestContext } from '../common/context/request-context.js';
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js'; import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { PageQuery } from '../common/page.dto.js';
@Injectable() @Injectable()
export class UsersService extends BaseCrudService { export class UsersService extends BaseCrudService {
@ -19,9 +18,13 @@ export class UsersService extends BaseCrudService {
return this.omitPasswordHash(created); return this.omitPasswordHash(created);
} }
async listUsers(q: PageQuery) { async listUsers(q: ListUserQuery) {
const ctx = getRequestContext(); const ctx = getRequestContext();
const where: Record<string, unknown> = {}; 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 为空表示「全部可见」,否则限制在可见部门集合 // 数据权限:orgCodes 为空表示「全部可见」,否则限制在可见部门集合
if (ctx && ctx.orgCodes.length > 0) { if (ctx && ctx.orgCodes.length > 0) {
where.orgCode = { in: ctx.orgCodes }; where.orgCode = { in: ctx.orgCodes };