diff --git a/apps/api/src/common/pipes/zod-validation.pipe.ts b/apps/api/src/common/pipes/zod-validation.pipe.ts index bac77e2..1b2e937 100644 --- a/apps/api/src/common/pipes/zod-validation.pipe.ts +++ b/apps/api/src/common/pipes/zod-validation.pipe.ts @@ -1,6 +1,6 @@ import { ErrorCode } from '@lingxi/shared'; import { type PipeTransform } from '@nestjs/common'; -import type { ZodSchema } from 'zod'; +import type { ZodType, ZodTypeDef } from 'zod'; import { AppException } from '../errors/app-exception'; @@ -9,7 +9,7 @@ import { AppException } from '../errors/app-exception'; * 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。 */ export class ZodValidationPipe implements PipeTransform { - constructor(private readonly schema: ZodSchema) {} + constructor(private readonly schema: ZodType) {} transform(value: unknown): T { const result = this.schema.safeParse(value); diff --git a/apps/api/src/modules/identity/departments.controller.ts b/apps/api/src/modules/identity/departments.controller.ts index 27c0315..8868138 100644 --- a/apps/api/src/modules/identity/departments.controller.ts +++ b/apps/api/src/modules/identity/departments.controller.ts @@ -1,28 +1,21 @@ -import { Permission, ulidSchema } from '@lingxi/shared'; -import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { z } from 'zod'; +import { + type CreateDepartmentInput, + createDepartmentInputSchema, + type DepartmentNode, + type DepartmentTreeQuery, + departmentTreeQuerySchema, + Permission, + type UpdateDepartmentInput, + updateDepartmentInputSchema, +} from '@lingxi/shared'; +import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; import { Audit, RequirePermissions } from '../../common/security/decorators'; import { DepartmentsService, type DepartmentTreeNode } from './departments.service'; -const createDepartmentSchema = z - .object({ - name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符'), - parentId: ulidSchema.nullable().default(null), - }) - .strict(); - -type CreateDepartmentBody = z.infer; - -const updateDepartmentSchema = z - .object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') }) - .strict(); - -type UpdateDepartmentBody = z.infer; - -/** 组织架构:两级部门树(A1 前端只读;写接口先备好,供后续批次使用) */ +/** 组织架构:两级部门树 + 新建 / 更新 / 停用 / 启用(不提供删除) */ @ApiTags('identity-departments') @ApiBearerAuth() @Controller('departments') @@ -31,30 +24,70 @@ export class DepartmentsController { @Get('tree') @RequirePermissions(Permission.USER_VIEW) - @ApiOperation({ summary: '部门树(两级,含成员数与负责人)' }) - async tree(): Promise<{ items: DepartmentTreeNode[] }> { - return { items: await this.departments.tree() }; + @ApiOperation({ + summary: '部门树(两级)', + description: + 'memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true', + }) + @ApiQuery({ name: 'include_disabled', required: false, enum: ['true', 'false'] }) + async tree( + @Query(new ZodValidationPipe(departmentTreeQuerySchema)) query: DepartmentTreeQuery, + ): Promise<{ items: DepartmentTreeNode[] }> { + return { items: await this.departments.tree(query.include_disabled) }; } @Post() @RequirePermissions(Permission.USER_MANAGE) @Audit('identity.department.create') - @ApiOperation({ summary: '新建部门 / 小组', description: '最多两级,在小组下新建返回 42201' }) + @ApiOperation({ + summary: '新建部门 / 小组', + description: '最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903', + }) async create( - @Body(new ZodValidationPipe(createDepartmentSchema)) input: CreateDepartmentBody, + @Body(new ZodValidationPipe(createDepartmentInputSchema)) input: CreateDepartmentInput, ): Promise<{ id: string }> { return this.departments.create(input); } @Patch(':id') @RequirePermissions(Permission.USER_MANAGE) - @Audit('identity.department.rename') - @ApiOperation({ summary: '部门改名' }) - async rename( + @Audit('identity.department.update') + @ApiOperation({ + summary: '更新部门(名称 / 负责人 / 排序)', + description: '上级关系创建后不可移动;负责人必须是启用用户', + }) + async update( @Param('id') id: string, - @Body(new ZodValidationPipe(updateDepartmentSchema)) input: UpdateDepartmentBody, + @Body(new ZodValidationPipe(updateDepartmentInputSchema)) input: UpdateDepartmentInput, ): Promise<{ id: string }> { - await this.departments.rename(id, input.name); + await this.departments.update(id, input); return { id }; } + + @Post(':id/disable') + @HttpCode(200) + @RequirePermissions(Permission.USER_MANAGE) + @Audit('identity.department.disable') + @ApiOperation({ + summary: '停用部门', + description: + '本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903', + }) + async disable( + @Param('id') id: string, + ): Promise<{ id: string; status: DepartmentNode['status'] }> { + return this.departments.disable(id); + } + + @Post(':id/enable') + @HttpCode(200) + @RequirePermissions(Permission.USER_MANAGE) + @Audit('identity.department.enable') + @ApiOperation({ + summary: '启用部门', + description: '上级必须启用;已启用返回 40903', + }) + async enable(@Param('id') id: string): Promise<{ id: string; status: DepartmentNode['status'] }> { + return this.departments.enable(id); + } } diff --git a/apps/api/src/modules/identity/departments.int.spec.ts b/apps/api/src/modules/identity/departments.int.spec.ts new file mode 100644 index 0000000..226cb8e --- /dev/null +++ b/apps/api/src/modules/identity/departments.int.spec.ts @@ -0,0 +1,284 @@ +import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp, type CreatedApiApp } from '../../bootstrap'; +import { loadDotenv } from '../../config/load-dotenv'; + +/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */ +const PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; +const PASSWORD = 'lingxi2026'; +const API = API_PREFIX; +const SUFFIX = String(Date.now()).slice(-6); + +const prisma = new PrismaClient(); + +interface Node { + id: string; + name: string; + parentId: string | null; + status: 'active' | 'disabled'; + sortOrder: number; + memberCount: number; + directMemberCount: number; + leader: { id: string; name: string } | null; + children: Node[]; +} + +interface ErrorBody { + code: number; + message: string; +} + +function departmentName(label: string): string { + return `测试${label}${SUFFIX}`; +} + +async function api( + app: CreatedApiApp['app'], + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + url: string, + token: string, + payload?: unknown, +): Promise<{ status: number; body: unknown }> { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}` }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + return { status: response.statusCode, body: response.json() }; +} + +describe('departments 集成(真实 PG)', () => { + let created: CreatedApiApp; + let token = ''; + let managerId = ''; + let rootId = ''; + let childId = ''; + let siblingId = ''; + let disabledRootId = ''; + + async function createDept( + name: string, + parentId: string | null, + extra: Record = {}, + ): Promise { + const response = await api(created.app, 'POST', '/departments', token, { + name, + parentId, + ...extra, + }); + expect(response.status).toBe(201); + return (response.body as { id: string }).id; + } + + async function tree(includeDisabled: boolean): Promise { + const response = await api( + created.app, + 'GET', + `/departments/tree?include_disabled=${String(includeDisabled)}`, + token, + ); + expect(response.status).toBe(200); + return (response.body as { items: Node[] }).items; + } + + function findNode(nodes: Node[], id: string): Node | undefined { + for (const node of nodes) { + if (node.id === id) return node; + const found = findNode(node.children, id); + if (found !== undefined) return found; + } + return undefined; + } + + beforeAll(async () => { + loadDotenv(); + created = await createApp(); + await created.app.init(); + await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } }); + await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } }); + + const department = await prisma.department.findUniqueOrThrow({ where: { code: 'tech' } }); + const role = await prisma.role.findUniqueOrThrow({ where: { code: 'platform_admin' } }); + managerId = ulid(); + const account = `it.dep.${SUFFIX}`; + await prisma.user.create({ + data: { + id: managerId, + name: '部门集成测试', + account, + email: `${account}@lingxi.cn`, + phone: `136${String(Date.now()).slice(-8)}`, + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId: role.id }] }, + }, + }); + const login = await created.app.inject({ + method: 'POST', + url: `${API}/auth/login`, + payload: { account, password: PASSWORD }, + }); + expect(login.statusCode).toBe(200); + token = login.json<{ accessToken: string }>().accessToken; + + rootId = await createDept(departmentName('一级部门'), null); + childId = await createDept(departmentName('小组A'), rootId); + siblingId = await createDept(departmentName('小组B'), rootId); + + const disabledRoot = await prisma.department.create({ + data: { + id: ulid(), + code: `test-disabled-${SUFFIX}`, + name: departmentName('停用部门'), + status: 'DISABLED', + }, + select: { id: true }, + }); + disabledRootId = disabledRoot.id; + }); + + afterAll(async () => { + await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } }); + // 只清理本测试创建的部门(先子后父),避免误删种子 / 其他用例的数据 + await prisma.department.deleteMany({ + where: { name: { startsWith: '测试' }, parentId: { not: null } }, + }); + await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } }); + await prisma.$disconnect(); + await created.app.close(); + }); + + it('部门树:默认过滤停用,include_disabled=true 返回停用节点与新增字段', async () => { + const activeTree = await tree(false); + expect(findNode(activeTree, disabledRootId)).toBeUndefined(); + const root = findNode(activeTree, rootId); + expect(root).toBeDefined(); + expect(root?.status).toBe('active'); + expect(root?.sortOrder).toBe(0); + expect(root?.directMemberCount).toBe(0); + expect(root?.memberCount).toBe(0); + expect(root?.children.map((child) => child.id).sort()).toEqual([childId, siblingId].sort()); + + const fullTree = await tree(true); + expect(findNode(fullTree, disabledRootId)?.status).toBe('disabled'); + }); + + it('新建:两级可建、超两级 42201、同级重名 40903、负责人非法 42201', async () => { + const tooDeep = await api(created.app, 'POST', '/departments', token, { + name: departmentName('三级组'), + parentId: childId, + }); + expect(tooDeep.status).toBe(422); + expect((tooDeep.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION); + + const duplicated = await api(created.app, 'POST', '/departments', token, { + name: departmentName('小组A'), + parentId: rootId, + }); + expect(duplicated.status).toBe(409); + expect((duplicated.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + const badLeader = await api(created.app, 'POST', '/departments', token, { + name: departmentName('小组C'), + parentId: rootId, + leaderUserId: ulid(), + }); + expect(badLeader.status).toBe(422); + expect((badLeader.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION); + }); + + it('更新:名称 / 排序 / 负责人生效,重名 40903', async () => { + const renamed = departmentName('小组A改'); + const response = await api(created.app, 'PATCH', `/departments/${childId}`, token, { + name: renamed, + sortOrder: 3, + leaderUserId: managerId, + }); + expect(response.status).toBe(200); + const root = findNode(await tree(false), rootId); + const child = root?.children.find((item) => item.id === childId); + expect(child?.name).toBe(renamed); + expect(child?.sortOrder).toBe(3); + expect(child?.leader?.id).toBe(managerId); + + const conflict = await api(created.app, 'PATCH', `/departments/${childId}`, token, { + name: departmentName('小组B'), + }); + expect(conflict.status).toBe(409); + }); + + it('停用:有在职成员 42201;存在启用子部门 42201;全部停用后可停用一级部门', async () => { + const member = await prisma.user.create({ + data: { + id: ulid(), + name: '临时成员', + account: `it.dep.member.${SUFFIX}`, + email: `it.dep.member.${SUFFIX}@lingxi.cn`, + phone: `138${String(Date.now()).slice(-8)}`, + passwordHash: PASSWORD_HASH, + departmentId: childId, + mustChangePassword: false, + }, + select: { id: true }, + }); + const blockedByMember = await api( + created.app, + 'POST', + `/departments/${childId}/disable`, + token, + ); + expect(blockedByMember.status).toBe(422); + expect((blockedByMember.body as ErrorBody).message).toContain('在职成员'); + + const blockedByChild = await api(created.app, 'POST', `/departments/${rootId}/disable`, token); + expect(blockedByChild.status).toBe(422); + expect((blockedByChild.body as ErrorBody).message).toMatch(/下级小组|在职成员/); + + await prisma.user.delete({ where: { id: member.id } }); + const disabledChild = await api(created.app, 'POST', `/departments/${childId}/disable`, token); + expect(disabledChild.status).toBe(200); + expect((disabledChild.body as { status: string }).status).toBe('disabled'); + expect(findNode(await tree(false), childId)).toBeUndefined(); + + const disabledSibling = await api( + created.app, + 'POST', + `/departments/${siblingId}/disable`, + token, + ); + expect(disabledSibling.status).toBe(200); + + const disabledRoot = await api(created.app, 'POST', `/departments/${rootId}/disable`, token); + expect(disabledRoot.status).toBe(200); + + const again = await api(created.app, 'POST', `/departments/${rootId}/disable`, token); + expect(again.status).toBe(409); + expect((again.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED); + }); + + it('启用:上级停用 42201;启用后上级可启用,子部门可再启用', async () => { + const childBlocked = await api(created.app, 'POST', `/departments/${childId}/enable`, token); + expect(childBlocked.status).toBe(422); + expect((childBlocked.body as ErrorBody).message).toContain('上级部门'); + + const rootEnabled = await api(created.app, 'POST', `/departments/${rootId}/enable`, token); + expect(rootEnabled.status).toBe(200); + + const childEnabled = await api(created.app, 'POST', `/departments/${childId}/enable`, token); + expect(childEnabled.status).toBe(200); + expect(findNode(await tree(false), childId)?.status).toBe('active'); + + const again = await api(created.app, 'POST', `/departments/${childId}/enable`, token); + expect(again.status).toBe(409); + }); + + it('不提供删除接口(DELETE 返回 404)', async () => { + const response = await api(created.app, 'DELETE', `/departments/${childId}`, token); + expect(response.status).toBe(404); + }); +}); diff --git a/apps/api/src/modules/identity/departments.service.ts b/apps/api/src/modules/identity/departments.service.ts index 86b3bbe..30a1be2 100644 --- a/apps/api/src/modules/identity/departments.service.ts +++ b/apps/api/src/modules/identity/departments.service.ts @@ -1,4 +1,10 @@ -import { type DepartmentNode, ErrorCode, ulid } from '@lingxi/shared'; +import { + type CreateDepartmentInput, + type DepartmentNode, + ErrorCode, + ulid, + type UpdateDepartmentInput, +} from '@lingxi/shared'; import { Inject, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -6,14 +12,24 @@ import { AppException } from '../../common/errors/app-exception'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { PRISMA } from '../../infra/tokens'; +/** 带负责人的部门行(树查询 select 结果) */ +type DepartmentRow = Prisma.DepartmentGetPayload<{ + include: { leader: { select: { id: true; name: true } } }; +}>; + /** 部门树节点:契约节点 + 子节点(最多两级) */ export interface DepartmentTreeNode extends DepartmentNode { children: DepartmentTreeNode[]; } -export interface CreateDepartmentPayload { - name: string; - parentId: string | null; +/** 状态在 DB 中用大写枚举值(与 users/roles 一致),API 契约用小写 */ +const DB_ACTIVE = 'ACTIVE'; +const DB_DISABLED = 'DISABLED'; + +const DUPLICATE_NAME_MESSAGE = '同级部门名称已存在'; + +function toApiStatus(status: string): DepartmentNode['status'] { + return status === DB_ACTIVE ? 'active' : 'disabled'; } function codeOf(name: string): string { @@ -27,19 +43,20 @@ function codeOf(name: string): string { function mapDepartmentError(error: unknown): AppException { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { - return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '同级部门名称已存在' }); + return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: DUPLICATE_NAME_MESSAGE }); } return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' }); } -/** 组织架构:两级部门树(含成员数与负责人)、新建、改名 */ +/** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */ @Injectable() export class DepartmentsService { constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} - async tree(): Promise { - const [departments, counts] = await Promise.all([ + async tree(includeDisabled: boolean): Promise { + const [departments, counts, lineage] = await Promise.all([ this.prisma.department.findMany({ + where: includeDisabled ? {} : { status: DB_ACTIVE }, orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }], include: { leader: { select: { id: true, name: true } } }, }), @@ -48,16 +65,30 @@ export class DepartmentsService { where: { status: 'ACTIVE' }, _count: { _all: true }, }), + // 全量父子关系:即使子部门被过滤,也要把其直属在职成员计入一级部门 memberCount + this.prisma.department.findMany({ select: { id: true, parentId: true } }), ]); const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all])); + return this.buildTree(departments, byDept, lineage); + } + + private buildTree( + departments: DepartmentRow[], + byDept: Map, + lineage: { id: string; parentId: string | null }[], + ): DepartmentTreeNode[] { const nodes = new Map(); for (const department of departments) { + const direct = byDept.get(department.id) ?? 0; nodes.set(department.id, { id: department.id, name: department.name, parentId: department.parentId, leader: department.leader, - memberCount: byDept.get(department.id) ?? 0, + status: toApiStatus(department.status), + sortOrder: department.sortOrder, + memberCount: direct, + directMemberCount: direct, createdAt: department.createdAt.toISOString(), children: [], }); @@ -68,25 +99,36 @@ export class DepartmentsService { roots.push(node); continue; } - const parent = nodes.get(node.parentId); - if (parent !== undefined) { - parent.children.push(node); + nodes.get(node.parentId)?.children.push(node); + } + const childDirect = new Map(); + for (const row of lineage) { + if (row.parentId !== null) { + childDirect.set( + row.parentId, + (childDirect.get(row.parentId) ?? 0) + (byDept.get(row.id) ?? 0), + ); } } for (const root of roots) { - root.memberCount += root.children.reduce((sum, child) => sum + child.memberCount, 0); + root.memberCount += childDirect.get(root.id) ?? 0; } return roots; } - async create(input: CreateDepartmentPayload): Promise<{ id: string }> { + async create(input: CreateDepartmentInput): Promise<{ id: string }> { await this.assertParentUsable(input.parentId); + await this.assertLeaderUsable(input.leaderUserId); + await this.assertNameFree(input.name, input.parentId, null); try { const created = await this.prisma.department.create({ data: { id: ulid(), name: input.name, parentId: input.parentId, + leaderUserId: input.leaderUserId ?? null, + sortOrder: input.sortOrder ?? 0, + status: DB_ACTIVE, code: codeOf(input.name), }, select: { id: true }, @@ -97,26 +139,85 @@ export class DepartmentsService { } } - async rename(id: string, name: string): Promise { - const existing = await this.prisma.department.findUnique({ where: { id } }); - if (existing === null) { - throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' }); + async update(id: string, input: UpdateDepartmentInput): Promise { + const existing = await this.findOrThrow(id); + if (input.leaderUserId !== undefined) { + await this.assertLeaderUsable(input.leaderUserId); } + if (input.name !== undefined) { + await this.assertNameFree(input.name, existing.parentId, id); + } + const data: Prisma.DepartmentUpdateInput = { + ...(input.name === undefined ? {} : { name: input.name }), + ...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }), + ...(input.leaderUserId === undefined + ? {} + : { + leader: + input.leaderUserId === null + ? { disconnect: true } + : { connect: { id: input.leaderUserId } }, + }), + }; try { - await this.prisma.department.update({ where: { id }, data: { name } }); + await this.prisma.department.update({ where: { id }, data }); } catch (error) { throw mapDepartmentError(error); } } + async disable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> { + const existing = await this.findOrThrow(id); + if (existing.status !== DB_ACTIVE) { + throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是停用状态' }); + } + await this.assertNoActiveMembers(existing); + await this.prisma.department.update({ where: { id }, data: { status: DB_DISABLED } }); + return { id, status: 'disabled' }; + } + + async enable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> { + const existing = await this.findOrThrow(id); + if (existing.status === DB_ACTIVE) { + throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是启用状态' }); + } + await this.assertParentActiveForEnable(existing.parentId); + await this.prisma.department.update({ where: { id }, data: { status: DB_ACTIVE } }); + return { id, status: 'active' }; + } + + private async findOrThrow(id: string): Promise<{ + id: string; + parentId: string | null; + status: string; + }> { + const department = await this.prisma.department.findUnique({ + where: { id }, + select: { id: true, parentId: true, status: true }, + }); + if (department === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' }); + } + return department; + } + private async assertParentUsable(parentId: string | null): Promise { if (parentId === null) { return; } - const parent = await this.prisma.department.findUnique({ where: { id: parentId } }); + const parent = await this.prisma.department.findUnique({ + where: { id: parentId }, + select: { parentId: true, status: true }, + }); if (parent === null) { throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' }); } + if (parent.status !== DB_ACTIVE) { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '上级部门已停用,无法在其下新建部门', + }); + } if (parent.parentId !== null) { throw new AppException({ code: ErrorCode.BUSINESS_RULE_VIOLATION, @@ -124,4 +225,93 @@ export class DepartmentsService { }); } } + + private async assertLeaderUsable(leaderUserId: string | null | undefined): Promise { + if (leaderUserId === null || leaderUserId === undefined) { + return; + } + const user = await this.prisma.user.findUnique({ + where: { id: leaderUserId }, + select: { status: true }, + }); + if (user?.status !== 'ACTIVE') { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '负责人必须是启用状态的用户', + }); + } + } + + private async assertNameFree( + name: string, + parentId: string | null, + excludeId: string | null, + ): Promise { + const duplicated = await this.prisma.department.findFirst({ + where: { + parentId, + name, + ...(excludeId === null ? {} : { id: { not: excludeId } }), + }, + select: { id: true }, + }); + if (duplicated !== null) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: DUPLICATE_NAME_MESSAGE, + }); + } + } + + private async assertNoActiveMembers(department: { + id: string; + parentId: string | null; + }): Promise { + const direct = await this.prisma.user.count({ + where: { departmentId: department.id, status: 'ACTIVE' }, + }); + if (direct > 0) { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: `部门下仍有 ${String(direct)} 名在职成员,请先调整成员所属部门后再停用`, + }); + } + if (department.parentId !== null) { + return; + } + const activeChildren = await this.prisma.department.count({ + where: { parentId: department.id, status: DB_ACTIVE }, + }); + if (activeChildren > 0) { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: `存在 ${String(activeChildren)} 个启用中的下级小组,请先停用下级小组`, + }); + } + const childMembers = await this.prisma.user.count({ + where: { status: 'ACTIVE', department: { parentId: department.id } }, + }); + if (childMembers > 0) { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: `下级小组仍有 ${String(childMembers)} 名在职成员,请先调整成员所属部门`, + }); + } + } + + private async assertParentActiveForEnable(parentId: string | null): Promise { + if (parentId === null) { + return; + } + const parent = await this.prisma.department.findUnique({ + where: { id: parentId }, + select: { status: true }, + }); + if (parent?.status !== DB_ACTIVE) { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '上级部门已停用,请先启用上级部门', + }); + } + } } diff --git a/apps/api/src/modules/identity/identity.services.spec.ts b/apps/api/src/modules/identity/identity.services.spec.ts index c710955..ffdd3bf 100644 --- a/apps/api/src/modules/identity/identity.services.spec.ts +++ b/apps/api/src/modules/identity/identity.services.spec.ts @@ -293,73 +293,253 @@ describe('UsersService', () => { }); describe('DepartmentsService', () => { - it('部门树:两级嵌套且一级成员数含下级', async () => { - const departments = [ - { - id: 'd1', - code: 'content', - name: '内容生产中心', - parentId: null, - leaderUserId: null, - sortOrder: 1, - status: 'ACTIVE', - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date(), - leader: { id: 'u1', name: '陈默' }, - }, - { - id: 'g1', - code: 'content-g1', - name: '素材一组', - parentId: 'd1', - leaderUserId: null, - sortOrder: 1, - status: 'ACTIVE', - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date(), - leader: null, - }, + function serviceOf(prisma: Record): DepartmentsService { + return new DepartmentsService(prisma as unknown as PrismaService); + } + + function departmentRow(overrides: Record = {}): Record { + return { + id: 'd1', + code: 'content', + name: '内容生产中心', + parentId: null, + leaderUserId: null, + sortOrder: 0, + status: 'ACTIVE', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date(), + leader: null, + ...overrides, + }; + } + + it('部门树:默认只返回启用部门;memberCount 含下级、directMemberCount 仅直属', async () => { + const rows = [ + departmentRow({ id: 'd1', name: '内容生产中心' }), + departmentRow({ id: 'g1', name: '素材一组', parentId: 'd1' }), ]; - const service = new DepartmentsService({ - department: { findMany: vi.fn().mockResolvedValue(departments) }, - user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) }, - } as unknown as PrismaService); - const tree = await service.tree(); + const findMany = vi.fn().mockResolvedValue(rows); + const service = serviceOf({ + department: { findMany }, + user: { + groupBy: vi.fn().mockResolvedValue([ + { departmentId: 'd1', _count: { _all: 3 } }, + { departmentId: 'g1', _count: { _all: 5 } }, + ]), + }, + }); + const tree = await service.tree(false); + expect(findMany.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ where: { status: 'ACTIVE' } }), + ); expect(tree).toHaveLength(1); - expect(tree[0]?.memberCount).toBe(5); + expect(tree[0]?.memberCount).toBe(8); + expect(tree[0]?.directMemberCount).toBe(3); expect(tree[0]?.children[0]?.name).toBe('素材一组'); + expect(tree[0]?.children[0]?.memberCount).toBe(5); + expect(tree[0]?.status).toBe('active'); }); - it('新建:父级不存在 40001,超过两级 42201', async () => { - const service = new DepartmentsService({ + it('部门树:include_disabled=true 时包含停用部门且状态映射为 disabled', async () => { + const service = serviceOf({ department: { - findUnique: vi.fn().mockResolvedValueOnce(null), + findMany: vi + .fn() + .mockResolvedValue([ + departmentRow({ id: 'd1' }), + departmentRow({ id: 'd2', status: 'DISABLED' }), + ]), }, - } as unknown as PrismaService); + user: { groupBy: vi.fn().mockResolvedValue([]) }, + }); + const tree = await service.tree(true); + expect(tree).toHaveLength(2); + expect(tree[1]?.status).toBe('disabled'); + expect(tree[0]?.sortOrder).toBe(0); + }); + + it('新建:父级不存在 40001、父级停用 / 超两级 42201、负责人非法 42201', async () => { await expectRejected( - service.create({ name: '新组', parentId: 'missing' }), + serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).create({ + name: '新部门', + parentId: 'missing', + }), ErrorCode.PARAM_INVALID, ); - const service2 = new DepartmentsService({ - department: { - findUnique: vi.fn().mockResolvedValue({ - id: 'g1', - parentId: 'd1', - }), - }, - } as unknown as PrismaService); await expectRejected( - service2.create({ name: '三级', parentId: 'g1' }), + serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue({ parentId: null, status: 'DISABLED' }), + }, + }).create({ name: '新小组', parentId: 'd1' }), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + + await expectRejected( + serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue({ parentId: 'd0', status: 'ACTIVE' }), + }, + }).create({ name: '三级组', parentId: 'g1' }), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + + await expectRejected( + serviceOf({ + department: { findFirst: vi.fn() }, + user: { findUnique: vi.fn().mockResolvedValue({ status: 'DISABLED' }) }, + }).create({ name: '新部门', parentId: null, leaderUserId: 'u-disabled' }), ErrorCode.BUSINESS_RULE_VIOLATION, ); }); - it('重命名不存在的部门返回 40401', async () => { - const service = new DepartmentsService({ - department: { findUnique: vi.fn().mockResolvedValue(null) }, - } as unknown as PrismaService); - await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND); + it('新建:同级重名 40903,成功后落库 sortOrder / status / 负责人', async () => { + const duplicated = serviceOf({ + department: { findFirst: vi.fn().mockResolvedValue({ id: 'other' }) }, + }); + await expectRejected( + duplicated.create({ name: '内容生产中心', parentId: null }), + ErrorCode.STATE_NOT_ALLOWED, + ); + + const create = vi.fn().mockResolvedValue({ id: 'new-id' }); + const service = serviceOf({ + department: { findFirst: vi.fn().mockResolvedValue(null), create }, + user: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) }, + }); + await expect( + service.create({ name: '内容生产中心', parentId: null, leaderUserId: 'u1', sortOrder: 5 }), + ).resolves.toEqual({ id: 'new-id' }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ sortOrder: 5, status: 'ACTIVE', leaderUserId: 'u1' }), + }), + ); + }); + + it('更新:不存在 40401、负责人非法 42201、重名 40903、支持清空负责人', async () => { + await expectRejected( + serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).update('missing', { + name: '新名', + }), + ErrorCode.NOT_FOUND, + ); + + await expectRejected( + serviceOf({ + department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) }, + user: { findUnique: vi.fn().mockResolvedValue(null) }, + }).update('d1', { leaderUserId: 'u-missing' }), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + + await expectRejected( + serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue(departmentRow()), + findFirst: vi.fn().mockResolvedValue({ id: 'other' }), + }, + }).update('d1', { name: '旧部门' }), + ErrorCode.STATE_NOT_ALLOWED, + ); + + const update = vi.fn().mockResolvedValue(departmentRow()); + const service = serviceOf({ + department: { findUnique: vi.fn().mockResolvedValue(departmentRow()), update }, + }); + await service.update('d1', { leaderUserId: null, sortOrder: 2 }); + expect(update).toHaveBeenCalledWith({ + where: { id: 'd1' }, + data: { sortOrder: 2, leader: { disconnect: true } }, + }); + }); + + it('停用:已停用 40903;本部门有在职成员 / 启用子部门 / 下级成员分别 42201', async () => { + await expectRejected( + serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })), + }, + }).disable('d1'), + ErrorCode.STATE_NOT_ALLOWED, + ); + + await expectRejected( + serviceOf({ + department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) }, + user: { count: vi.fn().mockResolvedValue(2) }, + }).disable('d1'), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + + await expectRejected( + serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue(departmentRow()), + count: vi.fn().mockResolvedValue(1), + }, + user: { count: vi.fn().mockResolvedValue(0) }, + }).disable('d1'), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + + await expectRejected( + serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue(departmentRow()), + count: vi.fn().mockResolvedValue(0), + }, + user: { count: vi.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(3) }, + }).disable('d1'), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + }); + + it('停用成功:无成员无启用子部门时落库 DISABLED;不提供删除方法', async () => { + const update = vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })); + const service = serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue(departmentRow()), + count: vi.fn().mockResolvedValue(0), + update, + }, + user: { count: vi.fn().mockResolvedValue(0) }, + }); + await expect(service.disable('d1')).resolves.toEqual({ id: 'd1', status: 'disabled' }); + expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'DISABLED' } }); + expect('delete' in service).toBe(false); + }); + + it('启用:已启用 40903、上级停用 42201、成功后落库 ACTIVE', async () => { + await expectRejected( + serviceOf({ + department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) }, + }).enable('d1'), + ErrorCode.STATE_NOT_ALLOWED, + ); + + await expectRejected( + serviceOf({ + department: { + findUnique: vi + .fn() + .mockResolvedValueOnce(departmentRow({ status: 'DISABLED', parentId: 'd0' })) + .mockResolvedValueOnce({ status: 'DISABLED' }), + }, + }).enable('g1'), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + + const update = vi.fn().mockResolvedValue(departmentRow()); + const service = serviceOf({ + department: { + findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })), + update, + }, + }); + await expect(service.enable('d1')).resolves.toEqual({ id: 'd1', status: 'active' }); + expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'ACTIVE' } }); }); }); diff --git a/apps/api/src/modules/identity/users.service.ts b/apps/api/src/modules/identity/users.service.ts index 934018c..b0f7f4c 100644 --- a/apps/api/src/modules/identity/users.service.ts +++ b/apps/api/src/modules/identity/users.service.ts @@ -219,10 +219,19 @@ export class UsersService { } private async ensureDepartment(departmentId: string): Promise { - const department = await this.prisma.department.findUnique({ where: { id: departmentId } }); + const department = await this.prisma.department.findUnique({ + where: { id: departmentId }, + select: { status: true }, + }); if (department === null) { throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' }); } + if (department.status !== 'ACTIVE') { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '所属部门已停用,请选择启用中的部门', + }); + } } private async ensureRoles(roleIds: string[]): Promise { diff --git a/apps/web/src/api/departments.spec.ts b/apps/web/src/api/departments.spec.ts new file mode 100644 index 0000000..a6a697f --- /dev/null +++ b/apps/web/src/api/departments.spec.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { + createDepartment, + disableDepartment, + enableDepartment, + fetchDepartments, + updateDepartment, +} from './identity'; + +const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const ROOT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; +const CHILD_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3'; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const root = { + id: ROOT_ID, + name: '内容生产中心', + parentId: null, + leader: { id: USER_ID, name: '陈默' }, + status: 'active', + sortOrder: 1, + memberCount: 19, + directMemberCount: 4, + createdAt: '2026-11-01T00:00:00.000Z', + children: [ + { + id: CHILD_ID, + name: '素材一组', + parentId: ROOT_ID, + leader: null, + status: 'disabled', + sortOrder: 2, + memberCount: 9, + directMemberCount: 9, + createdAt: '2026-11-01T00:00:00.000Z', + children: [], + }, + ], +}; + +function calls(): { url: string; init: RequestInit | undefined }[] { + return vi.mocked(globalThis.fetch).mock.calls.map((call) => { + const input = call[0]; + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return { url, init: call[1] }; + }); +} + +function lastBody(): unknown { + const body = calls().at(-1)?.init?.body; + return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined; +} + +describe('部门接口', () => { + beforeEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + useAuthStore.getState().clearSession(); + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: USER_ID, name: '陈默', account: 'chen.mo' }, + permissions: ['user:view', 'user:manage'], + }); + }); + + it('部门树默认只取启用部门,includeDisabled 时带 query', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [root] })) + .mockResolvedValueOnce(jsonResponse({ items: [root] })); + const activeOnly = await fetchDepartments(); + expect(String(calls()[0]?.url)).toContain('/departments/tree?include_disabled=false'); + expect(activeOnly.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']); + expect(activeOnly[1]?.status).toBe('disabled'); + await fetchDepartments(true); + expect(String(calls()[1]?.url)).toContain('/departments/tree?include_disabled=true'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('新建返回部门 id,请求走 POST /departments', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse({ id: CHILD_ID })); + await expect( + createDepartment({ + name: '设计组', + parentId: ROOT_ID, + leaderUserId: null, + sortOrder: 3, + }), + ).resolves.toBe(CHILD_ID); + expect(calls()[0]?.init?.method).toBe('POST'); + expect(String(calls()[0]?.url)).toContain('/departments'); + expect(lastBody()).toEqual({ + name: '设计组', + parentId: ROOT_ID, + leaderUserId: null, + sortOrder: 3, + }); + }); + + it('更新 / 停用 / 启用使用约定方法与路径', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + await updateDepartment(CHILD_ID, { name: '设计组', leaderUserId: null }); + await disableDepartment(CHILD_ID); + await enableDepartment(CHILD_ID); + const requestLines = calls().map((call) => { + const url = new URL(call.url); + return `${call.init?.method ?? 'GET'} ${url.pathname.replace('/api/v1', '')}`; + }); + expect(requestLines).toEqual([ + `PATCH /departments/${CHILD_ID}`, + `POST /departments/${CHILD_ID}/disable`, + `POST /departments/${CHILD_ID}/enable`, + ]); + expect(lastBody()).toBeUndefined(); + }); +}); diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts index 0d5ca95..49d8c77 100644 --- a/apps/web/src/api/endpoints.ts +++ b/apps/web/src/api/endpoints.ts @@ -19,6 +19,12 @@ export const ENDPOINTS = { userTransferCustomers: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/transfer-customers`, departments: '/departments/tree', + departmentsRoot: '/departments', + department: (departmentId: string): string => `/departments/${encodeURIComponent(departmentId)}`, + departmentDisable: (departmentId: string): string => + `/departments/${encodeURIComponent(departmentId)}/disable`, + departmentEnable: (departmentId: string): string => + `/departments/${encodeURIComponent(departmentId)}/enable`, roles: '/roles', role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`, permissions: '/permissions', diff --git a/apps/web/src/api/identity.spec.ts b/apps/web/src/api/identity.spec.ts index 2a5a05a..bb983cc 100644 --- a/apps/web/src/api/identity.spec.ts +++ b/apps/web/src/api/identity.spec.ts @@ -110,7 +110,10 @@ describe('identity 接口封装', () => { name: '内容生产中心', parentId: null, leader: null, + status: 'active', + sortOrder: 1, memberCount: 19, + directMemberCount: 4, createdAt: '2026-09-01T00:00:00.000Z', children: [ { @@ -118,7 +121,10 @@ describe('identity 接口封装', () => { name: '素材一组', parentId: GROUP_ID, leader: null, + status: 'active', + sortOrder: 1, memberCount: 9, + directMemberCount: 9, createdAt: '2026-09-01T00:00:00.000Z', children: [], }, diff --git a/apps/web/src/api/identity.ts b/apps/web/src/api/identity.ts index 1ce6db6..3bd8c5b 100644 --- a/apps/web/src/api/identity.ts +++ b/apps/web/src/api/identity.ts @@ -1,6 +1,8 @@ import { type ChangePasswordInput, changePasswordInputSchema, + type CreateDepartmentInput, + createDepartmentInputSchema, type CreateUserInput, createUserInputSchema, type DepartmentNode, @@ -17,6 +19,8 @@ import { roleSummarySchema, type TransferCustomersInput, transferCustomersInputSchema, + type UpdateDepartmentInput, + updateDepartmentInputSchema, type UpdateUserInput, updateUserInputSchema, } from '@lingxi/shared'; @@ -32,7 +36,7 @@ export interface CustomerSummary { } const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) }); -const createdUserSchema = z.object({ id: z.string().min(1) }).passthrough(); +const createdIdSchema = z.object({ id: z.string().min(1) }).passthrough(); /** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */ function parseList(payload: unknown, itemSchema: z.ZodType): T[] { @@ -69,9 +73,13 @@ export async function fetchMe(): Promise { return meResponseSchema.parse(payload); } -/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */ -export async function fetchDepartments(): Promise { - const payload: unknown = await request(ENDPOINTS.departments); +/** + * 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用。 + * includeDisabled=true 时包含已停用部门(部门管理页需要),默认只看启用。 + */ +export async function fetchDepartments(includeDisabled = false): Promise { + const search = includeDisabled ? '?include_disabled=true' : '?include_disabled=false'; + const payload: unknown = await request(`${ENDPOINTS.departments}${search}`); const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload; if (!Array.isArray(raw)) { return []; @@ -93,6 +101,32 @@ export async function fetchDepartments(): Promise { return flat; } +export async function createDepartment(input: CreateDepartmentInput): Promise { + const body = createDepartmentInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.departmentsRoot, { method: 'POST', body }); + const parsed = createdIdSchema.safeParse(payload); + if (!parsed.success) { + throw new Error('部门创建成功但未返回部门 ID'); + } + return parsed.data.id; +} + +export async function updateDepartment( + departmentId: string, + input: UpdateDepartmentInput, +): Promise { + const body = updateDepartmentInputSchema.parse(input); + await request(ENDPOINTS.department(departmentId), { method: 'PATCH', body }); +} + +export async function disableDepartment(departmentId: string): Promise { + await request(ENDPOINTS.departmentDisable(departmentId), { method: 'POST' }); +} + +export async function enableDepartment(departmentId: string): Promise { + await request(ENDPOINTS.departmentEnable(departmentId), { method: 'POST' }); +} + export async function fetchRoles(): Promise { const payload: unknown = await request(ENDPOINTS.roles); return parseList(payload, roleSummarySchema); @@ -102,7 +136,7 @@ export async function fetchRoles(): Promise { export async function createUser(input: CreateUserInput): Promise { const body = createUserInputSchema.parse(input); const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body }); - const parsed = createdUserSchema.safeParse(payload); + const parsed = createdIdSchema.safeParse(payload); return parsed.success ? parsed.data.id : null; } diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index 33a4204..46aad77 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -8,7 +8,7 @@ export const queryKeys = { userList: (query: UserListQueryInput) => ['users', 'list', query] as const, user: (userId: string) => ['users', 'detail', userId] as const, userCustomers: (userId: string) => ['users', 'customers', userId] as const, - departments: ['departments'] as const, + departments: (includeDisabled: boolean) => ['departments', { includeDisabled }] as const, roles: ['roles'] as const, roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const, role: (roleId: string) => ['roles', 'detail', roleId] as const, diff --git a/apps/web/src/hooks/queries/use-directory.ts b/apps/web/src/hooks/queries/use-directory.ts index 0ec1aaa..26047e3 100644 --- a/apps/web/src/hooks/queries/use-directory.ts +++ b/apps/web/src/hooks/queries/use-directory.ts @@ -1,13 +1,32 @@ -import { type DepartmentNode, type RoleSummary } from '@lingxi/shared'; -import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import type { + CreateDepartmentInput, + DepartmentNode, + RoleSummary, + UpdateDepartmentInput, +} from '@lingxi/shared'; +import { + useMutation, + type UseMutationResult, + useQuery, + useQueryClient, + type UseQueryResult, +} from '@tanstack/react-query'; -import { fetchDepartments, fetchRoles } from '@/api/identity'; +import { + createDepartment, + disableDepartment, + enableDepartment, + fetchDepartments, + fetchRoles, + updateDepartment, +} from '@/api/identity'; import { queryKeys } from '@/api/query-keys'; -export function useDepartmentsQuery(): UseQueryResult { +/** 部门树:默认只取启用部门(用户页选择器);部门管理页传 includeDisabled=true */ +export function useDepartmentsQuery(includeDisabled = false): UseQueryResult { return useQuery({ - queryKey: queryKeys.departments, - queryFn: fetchDepartments, + queryKey: queryKeys.departments(includeDisabled), + queryFn: () => fetchDepartments(includeDisabled), staleTime: 5 * 60_000, }); } @@ -19,3 +38,63 @@ export function useRolesQuery(): UseQueryResult { staleTime: 5 * 60_000, }); } + +/** 部门写操作:失效部门树与用户缓存(用户列表/详情的部门名称与人数会变) */ +function useInvalidateDirectory(): () => Promise { + const queryClient = useQueryClient(); + return async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['departments'] }), + queryClient.invalidateQueries({ queryKey: queryKeys.users }), + ]); + }; +} + +export function useCreateDepartmentMutation(): UseMutationResult< + string, + Error, + CreateDepartmentInput +> { + const invalidate = useInvalidateDirectory(); + return useMutation({ + mutationFn: (input: CreateDepartmentInput) => createDepartment(input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useUpdateDepartmentMutation(): UseMutationResult< + void, + Error, + { departmentId: string; input: UpdateDepartmentInput } +> { + const invalidate = useInvalidateDirectory(); + return useMutation({ + mutationFn: ({ departmentId, input }: { departmentId: string; input: UpdateDepartmentInput }) => + updateDepartment(departmentId, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useDisableDepartmentMutation(): UseMutationResult { + const invalidate = useInvalidateDirectory(); + return useMutation({ + mutationFn: (departmentId: string) => disableDepartment(departmentId), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useEnableDepartmentMutation(): UseMutationResult { + const invalidate = useInvalidateDirectory(); + return useMutation({ + mutationFn: (departmentId: string) => enableDepartment(departmentId), + onSuccess: async () => { + await invalidate(); + }, + }); +} diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index 9fcd7a5..b888a8e 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -11,9 +11,13 @@ import { PageBreadcrumbs } from './PageBreadcrumbs'; import { SideNav } from './SideNav'; import { TopBar } from './TopBar'; -/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */ +/** 用户与组织、部门管理为「满高主从布局」,由页面自己控制内部滚动 */ function isFlushRoute(pathname: string): boolean { - return pathname === ROUTES.system.users || pathname.startsWith(`${ROUTES.system.users}/`); + return ( + pathname === ROUTES.system.users || + pathname.startsWith(`${ROUTES.system.users}/`) || + pathname === ROUTES.system.departments + ); } export function AppLayout(): ReactNode { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5cb2393..03a6c21 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,6 +8,7 @@ import '@/styles/pages.css'; import '@/styles/users.css'; import '@/styles/roles.css'; import '@/styles/role-editor.css'; +import '@/styles/departments.css'; import { LocaleProvider } from '@douyinfe/semi-ui-19'; import { localeZhCN } from '@lingxi/ui'; diff --git a/apps/web/src/navigation/nav-config.spec.ts b/apps/web/src/navigation/nav-config.spec.ts index f004560..bb9c143 100644 --- a/apps/web/src/navigation/nav-config.spec.ts +++ b/apps/web/src/navigation/nav-config.spec.ts @@ -44,6 +44,15 @@ describe('导航配置', () => { ); }); + it('部门管理菜单使用 user:view,仅查看权限也能进入', () => { + const departmentsItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find( + (item) => item.key === 'departments', + ); + expect(departmentsItem?.path).toBe('/system/departments'); + expect(departmentsItem?.permission).toBe('user:view'); + expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理'); + }); + it('按路径定位当前模块与菜单', () => { const active = findActiveNav(NAV_MODULES, '/material/quick-review'); expect(active?.module.key).toBe('material'); @@ -55,5 +64,7 @@ describe('导航配置', () => { expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]); const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/users'); expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '用户与组织']); + const deptBreadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/departments'); + expect(deptBreadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '部门管理']); }); }); diff --git a/apps/web/src/navigation/nav-config.ts b/apps/web/src/navigation/nav-config.ts index 8ec3eb5..181948e 100644 --- a/apps/web/src/navigation/nav-config.ts +++ b/apps/web/src/navigation/nav-config.ts @@ -1,5 +1,6 @@ import { Permission, type PermissionCode } from '@lingxi/shared'; import { + IconApps, IconAsset, IconCheck, IconClock, @@ -254,6 +255,14 @@ export const NAV_MODULES: readonly NavModule[] = [ permission: Permission.USER_VIEW, description: '账号、组织与登录状态', }, + { + key: 'departments', + label: '部门管理', + path: '/system/departments', + icon: IconApps, + permission: Permission.USER_VIEW, + description: '两级组织架构、负责人、排序与停用状态', + }, { key: 'roles', label: '角色管理', diff --git a/apps/web/src/pages/system/departments/DepartmentDetailPanel.tsx b/apps/web/src/pages/system/departments/DepartmentDetailPanel.tsx new file mode 100644 index 0000000..0f4ad79 --- /dev/null +++ b/apps/web/src/pages/system/departments/DepartmentDetailPanel.tsx @@ -0,0 +1,370 @@ +/* eslint-disable max-lines-per-function -- 详情面板按视图/编辑两种形态分区,集中便于与表单状态对齐 */ +import { Button, Input, InputNumber, Select } from '@douyinfe/semi-ui-19'; +import type { DepartmentNode, UserSummary } from '@lingxi/shared'; +import { Permission } from '@lingxi/shared'; +import { PageEmpty } from '@lingxi/ui'; +import { IconAdd, IconEdit, IconLock, IconPlay } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; + +import { Can } from '@/components/Can'; + +import type { DepartmentFormErrors, DepartmentFormValues } from './department-form'; +import { + childMemberCount, + departmentDisableBlocker, + departmentEnableBlocker, + type DepartmentTreeModel, +} from './department-tree'; +import { DepartmentStatusTag } from './DepartmentStatusTag'; + +export interface DepartmentDetailPanelProps { + node: DepartmentNode | null; + model: DepartmentTreeModel; + canManage: boolean; + editing: boolean; + form: DepartmentFormValues; + errors: DepartmentFormErrors; + saving: boolean; + saveError: string | null; + leaderCandidates: readonly UserSummary[]; + onStartEdit: () => void; + onCancelEdit: () => void; + onChangeField: (field: keyof DepartmentFormValues, value: string) => void; + onSave: () => void; + onDisable: () => void; + onEnable: () => void; + onCreateChild: () => void; + onSelect: (departmentId: string) => void; +} + +function DetailHead({ + node, + parent, + canManage, + disableBlocker, + enableBlocker, + onStartEdit, + onDisable, + onEnable, +}: { + node: DepartmentNode; + parent: DepartmentNode | null; + canManage: boolean; + disableBlocker: string | null; + enableBlocker: string | null; + onStartEdit: () => void; + onDisable: () => void; + onEnable: () => void; +}): ReactNode { + return ( +
+ +
+
+

{node.name}

+ + {node.parentId === null ? '一级部门' : '小组'} +
+

+ 上级 {parent?.name ?? '—'} · 负责人 {node.leader?.name ?? '未指定'} · 排序{' '} + {node.sortOrder} +

+
+ +
+ + {node.status === 'active' ? ( + + ) : ( + + )} +
+
+ {canManage ? null : 只读权限} +
+ ); +} + +function InfoGrid({ + node, + parent, +}: { + node: DepartmentNode; + parent: DepartmentNode | null; +}): ReactNode { + return ( +
+

基本信息

+
+
+ 部门名称 + {node.name} +
+
+ 上级部门 + {parent?.name ?? '—(一级部门)'} +
+
+ 负责人 + {node.leader?.name ?? '未指定'} +
+
+ 排序 + {node.sortOrder} +
+
+ 状态 + + + +
+
+ 创建时间 + {node.createdAt.slice(0, 16).replace('T', ' ')} +
+
+ 直属成员 + {node.directMemberCount} 人 +
+
+ 含下级成员 + {node.memberCount} 人 +
+
+
+ ); +} + +function ChildrenSection({ + node, + model, + onCreateChild, + onSelect, +}: { + node: DepartmentNode; + model: DepartmentTreeModel; + onCreateChild: () => void; + onSelect: (departmentId: string) => void; +}): ReactNode { + if (node.parentId !== null) { + return null; + } + const children = model.nodes.filter((child) => child.parentId === node.id); + return ( +
+
+

下级小组

+ {children.length} 个 + + + +
+ {children.length === 0 ? ( +

暂无下级小组。

+ ) : ( +
    + {children.map((child) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function EditForm({ + node, + form, + errors, + saving, + saveError, + leaderCandidates, + onChange, + onCancel, + onSave, +}: { + node: DepartmentNode; + form: DepartmentFormValues; + errors: DepartmentFormErrors; + saving: boolean; + saveError: string | null; + leaderCandidates: readonly UserSummary[]; + onChange: (field: keyof DepartmentFormValues, value: string) => void; + onCancel: () => void; + onSave: () => void; +}): ReactNode { + return ( +
+

编辑部门

+

+ 上级关系创建后不可移动;负责人仅可从启用中的用户选择。 +

+
+ + +