feat(identity): 补齐部门管理(两级组织树、停用启用)
ci / 质量门禁 (push) Canceled after 0s

背景:
- A1 只实现了部门树读取与新建 / 改名接口,没有部门管理入口,
  负责人、排序、停用状态都无法维护;
- 用户必须关联部门,部门无法维护会让 A2 的数据范围缺少组织基础。

改动:
- shared:DepartmentNode 增加 status / sortOrder / directMemberCount;
  新增 createDepartmentInput / updateDepartmentInput / departmentTreeQuery 契约;
- api:GET /departments/tree?include_disabled;POST/PATCH /departments
  (名称 / 负责人 / 排序);POST /departments/{id}/disable|enable;
  只停用不删除,有在职成员 / 启用子部门 / 下级在职成员时禁止停用;
  新建 / 编辑用户校验部门必须启用;@Audit 与 OpenAPI 同步;
- web:管理中心新增「部门管理」/system/departments,左两级组织树 +
  右详情 / 内联编辑 + 新建部门/小组 + 停用启用二次确认;显示已停用、
  ?id= 选中可恢复、无 user:manage 只读;用户页部门选择仍只取启用部门;
- 测试:shared 契约、api 单测 + int、web 树/表单/接口单测同步。

影响面:
- 新增部门写接口与页面;GET tree 默认仍只返回启用部门,对用户页无破坏;
- 权限复用 user:view / user:manage,不新增权限点;
- 无数据库迁移(status / leader_user_id / sort_order 列已存在)。

回滚方式:
- 回滚本提交即移除部门管理页与写接口,保留原只读树;数据库无需回滚。

验证证据:
- pnpm format:check / lint / typecheck 全绿;
- pnpm test:shared 121 / api 90 / web 120;test:int 4 files / 33 tests;
- pnpm openapi:diff:无破坏性变更;
- 真实 API + 浏览器(jiang.lan):新建一级部门 / 小组、改名、停用空小组、
  重名 40903、树/详情/停用确认均正常;
  截图 design/a1-ui-v1/shots/impl/70-departments.png。
This commit is contained in:
Stev_Wang committed 2026-11-03 15:01:07 +08:00
1 parent f4da1f3c02
commit 6661c42e84
33 files changed
+3228 -116

No files matched your search

@@ -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<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
constructor(private readonly schema: ZodType<T, ZodTypeDef, unknown>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
@@ -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<typeof createDepartmentSchema>;
const updateDepartmentSchema = z
.object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') })
.strict();
type UpdateDepartmentBody = z.infer<typeof updateDepartmentSchema>;
/** 组织架构:两级部门树(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);
}
}
@@ -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<string, unknown> }),
});
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<string, unknown> = {},
): Promise<string> {
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<Node[]> {
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);
});
});
@@ -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<DepartmentTreeNode[]> {
const [departments, counts] = await Promise.all([
async tree(includeDisabled: boolean): Promise<DepartmentTreeNode[]> {
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<string, number>,
lineage: { id: string; parentId: string | null }[],
): DepartmentTreeNode[] {
const nodes = new Map<string, DepartmentTreeNode>();
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<string, number>();
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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: '上级部门已停用,请先启用上级部门',
});
}
}
}
@@ -293,73 +293,253 @@ describe('UsersService', () => {
});
describe('DepartmentsService', () => {
it('部门树:两级嵌套且一级成员数含下级', async () => {
const departments = [
{
function serviceOf(prisma: Record<string, unknown>): DepartmentsService {
return new DepartmentsService(prisma as unknown as PrismaService);
}
function departmentRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
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,
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' } });
});
});
+10 -1
View File
@@ -219,10 +219,19 @@ export class UsersService {
}
private async ensureDepartment(departmentId: string): Promise<void> {
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<void> {
+127
View File
@@ -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();
});
});
+6
View File
@@ -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',
+6
View File
@@ -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: [],
},
+39 -5
View File
@@ -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<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
@@ -69,9 +73,13 @@ export async function fetchMe(): Promise<MeResponse> {
return meResponseSchema.parse(payload);
}
/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */
export async function fetchDepartments(): Promise<DepartmentNode[]> {
const payload: unknown = await request(ENDPOINTS.departments);
/**
* 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用。
* includeDisabled=true 时包含已停用部门(部门管理页需要),默认只看启用。
*/
export async function fetchDepartments(includeDisabled = false): Promise<DepartmentNode[]> {
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<DepartmentNode[]> {
return flat;
}
export async function createDepartment(input: CreateDepartmentInput): Promise<string> {
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<void> {
const body = updateDepartmentInputSchema.parse(input);
await request<unknown>(ENDPOINTS.department(departmentId), { method: 'PATCH', body });
}
export async function disableDepartment(departmentId: string): Promise<void> {
await request<unknown>(ENDPOINTS.departmentDisable(departmentId), { method: 'POST' });
}
export async function enableDepartment(departmentId: string): Promise<void> {
await request<unknown>(ENDPOINTS.departmentEnable(departmentId), { method: 'POST' });
}
export async function fetchRoles(): Promise<RoleSummary[]> {
const payload: unknown = await request(ENDPOINTS.roles);
return parseList(payload, roleSummarySchema);
@@ -102,7 +136,7 @@ export async function fetchRoles(): Promise<RoleSummary[]> {
export async function createUser(input: CreateUserInput): Promise<string | null> {
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;
}
+1 -1
View File
@@ -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,
+85 -6
View File
@@ -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<DepartmentNode[]> {
/** 部门树:默认只取启用部门(用户页选择器);部门管理页传 includeDisabled=true */
export function useDepartmentsQuery(includeDisabled = false): UseQueryResult<DepartmentNode[]> {
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<RoleSummary[]> {
staleTime: 5 * 60_000,
});
}
/** 部门写操作:失效部门树与用户缓存(用户列表/详情的部门名称与人数会变) */
function useInvalidateDirectory(): () => Promise<void> {
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<void, Error, string> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (departmentId: string) => disableDepartment(departmentId),
onSuccess: async () => {
await invalidate();
},
});
}
export function useEnableDepartmentMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (departmentId: string) => enableDepartment(departmentId),
onSuccess: async () => {
await invalidate();
},
});
}
+6 -2
View File
@@ -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 {
+1
View File
@@ -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';
@@ -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(['工作台', '管理中心', '部门管理']);
});
});
+9
View File
@@ -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: '角色管理',
@@ -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 (
<header className="lx-dept-detail__head">
<span className="lx-dept-detail__avatar" aria-hidden="true">
{node.name.slice(0, 1)}
</span>
<div className="lx-dept-detail__ident">
<div className="lx-dept-detail__name">
<h2>{node.name}</h2>
<DepartmentStatusTag status={node.status} />
<span className="lx-dept-tag">{node.parentId === null ? '一级部门' : '小组'}</span>
</div>
<p className="lx-dept-detail__meta">
上级 {parent?.name ?? '—'} · 负责人 {node.leader?.name ?? '未指定'} · 排序{' '}
{node.sortOrder}
</p>
</div>
<Can permission={Permission.USER_MANAGE}>
<div className="lx-dept-detail__actions">
<Button icon={<IconEdit size={14} />} onClick={onStartEdit}>
编辑
</Button>
{node.status === 'active' ? (
<Button
type="danger"
icon={<IconLock size={14} />}
disabled={disableBlocker !== null}
title={disableBlocker ?? undefined}
onClick={onDisable}
>
停用
</Button>
) : (
<Button
icon={<IconPlay size={14} />}
disabled={enableBlocker !== null}
title={enableBlocker ?? undefined}
onClick={onEnable}
>
启用
</Button>
)}
</div>
</Can>
{canManage ? null : <span className="lx-dept-detail__readonly">只读权限</span>}
</header>
);
}
function InfoGrid({
node,
parent,
}: {
node: DepartmentNode;
parent: DepartmentNode | null;
}): ReactNode {
return (
<section className="lx-dept-detail__section">
<h3 className="lx-dept-detail__section-title">基本信息</h3>
<div className="lx-dept-kv-grid">
<div className="lx-kvi">
<span className="lx-kvi__label">部门名称</span>
<span className="lx-kvi__value">{node.name}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">上级部门</span>
<span className="lx-kvi__value">{parent?.name ?? '—(一级部门)'}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">负责人</span>
<span className="lx-kvi__value">{node.leader?.name ?? '未指定'}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">排序</span>
<span className="lx-kvi__value">{node.sortOrder}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">状态</span>
<span className="lx-kvi__value">
<DepartmentStatusTag status={node.status} />
</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">创建时间</span>
<span className="lx-kvi__value">{node.createdAt.slice(0, 16).replace('T', ' ')}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">直属成员</span>
<span className="lx-kvi__value">{node.directMemberCount} 人</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">含下级成员</span>
<span className="lx-kvi__value">{node.memberCount} 人</span>
</div>
</div>
</section>
);
}
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 (
<section className="lx-dept-detail__section">
<div className="lx-dept-detail__section-head">
<h3 className="lx-dept-detail__section-title">下级小组</h3>
<span className="lx-dept-detail__muted">{children.length} 个</span>
<Can permission={Permission.USER_MANAGE}>
<Button size="small" icon={<IconAdd size={14} />} onClick={onCreateChild}>
新建小组
</Button>
</Can>
</div>
{children.length === 0 ? (
<p className="lx-dept-detail__muted">暂无下级小组。</p>
) : (
<ul className="lx-dept-children">
{children.map((child) => (
<li key={child.id}>
<button
type="button"
className="lx-dept-child-row"
onClick={() => {
onSelect(child.id);
}}
>
<span className="lx-dept-child-row__name">
{child.name}
<DepartmentStatusTag status={child.status} />
</span>
<span className="lx-dept-child-row__meta">
负责人 {child.leader?.name ?? '未指定'} · {child.memberCount} 人
</span>
</button>
</li>
))}
</ul>
)}
</section>
);
}
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 (
<section className="lx-dept-detail__section lx-dept-edit">
<h3 className="lx-dept-detail__section-title">编辑部门</h3>
<p className="lx-dept-detail__muted">
上级关系创建后不可移动;负责人仅可从启用中的用户选择。
</p>
<div className="lx-dept-edit__grid">
<label className="lx-field">
<span className="lx-field__label">部门名称</span>
<Input
value={form.name}
maxLength={20}
placeholder="请输入部门名称"
onChange={(value) => {
onChange('name', value);
}}
/>
{errors.name === undefined ? null : (
<span className="lx-field__error">{errors.name}</span>
)}
</label>
<label className="lx-field">
<span className="lx-field__label">负责人</span>
<Select
value={form.leaderUserId === '' ? undefined : form.leaderUserId}
placeholder="未指定"
showClear
optionList={leaderCandidates.map((user) => ({
label: `${user.name} · ${user.account}`,
value: user.id,
}))}
onChange={(value) => {
onChange('leaderUserId', typeof value === 'string' ? value : '');
}}
/>
</label>
<label className="lx-field">
<span className="lx-field__label">排序</span>
<InputNumber
value={form.sortOrder === '' ? undefined : Number(form.sortOrder)}
min={0}
max={9999}
placeholder="0"
onChange={(value) => {
onChange('sortOrder', String(value));
}}
/>
{errors.sortOrder === undefined ? null : (
<span className="lx-field__error">{errors.sortOrder}</span>
)}
</label>
</div>
{saveError === null ? null : <p className="lx-field__error">{saveError}</p>}
<div className="lx-dept-edit__actions">
<Button onClick={onCancel} disabled={saving}>
取消
</Button>
<Button theme="solid" type="primary" loading={saving} onClick={onSave}>
保存
</Button>
</div>
<p className="lx-dept-detail__muted">当前编辑:{node.name}</p>
</section>
);
}
/** 右侧部门详情 / 内联编辑 */
export function DepartmentDetailPanel(props: DepartmentDetailPanelProps): ReactNode {
const { node, model } = props;
if (node === null) {
return (
<section className="lx-dept-detail" aria-label="部门详情">
<div className="lx-dept-detail__empty">
<PageEmpty title="选择左侧部门查看详情" description="支持新建一级部门与下级小组。" />
</div>
</section>
);
}
const parent = node.parentId === null ? null : (model.byId.get(node.parentId) ?? null);
const children = model.nodes.filter((child) => child.parentId === node.id);
const disableBlocker = departmentDisableBlocker(node, children);
const enableBlocker = departmentEnableBlocker(node, parent);
return (
<section className="lx-dept-detail" aria-label="部门详情">
<DetailHead
node={node}
parent={parent}
canManage={props.canManage}
disableBlocker={disableBlocker}
enableBlocker={enableBlocker}
onStartEdit={props.onStartEdit}
onDisable={props.onDisable}
onEnable={props.onEnable}
/>
<div className="lx-dept-detail__body">
{disableBlocker !== null && node.status === 'active' ? (
<p className="lx-dept-notice" role="status">
当前不可停用:{disableBlocker}
</p>
) : null}
{enableBlocker !== null && node.status === 'disabled' ? (
<p className="lx-dept-notice" role="status">
当前不可启用:{enableBlocker}
</p>
) : null}
{props.editing ? (
<EditForm
node={node}
form={props.form}
errors={props.errors}
saving={props.saving}
saveError={props.saveError}
leaderCandidates={props.leaderCandidates}
onChange={props.onChangeField}
onCancel={props.onCancelEdit}
onSave={props.onSave}
/>
) : (
<>
<InfoGrid node={node} parent={parent} />
<ChildrenSection
node={node}
model={model}
onCreateChild={props.onCreateChild}
onSelect={props.onSelect}
/>
{childMemberCount(node) > 0 ? (
<p className="lx-dept-detail__muted">
含下级成员 {node.memberCount} 人,其中直属 {node.directMemberCount} 人。
</p>
) : null}
</>
)}
</div>
</section>
);
}
@@ -0,0 +1,17 @@
import type { DepartmentStatus } from '@lingxi/shared';
import type { ReactNode } from 'react';
export interface DepartmentStatusTagProps {
status: DepartmentStatus;
}
/** 部门状态标签:启用 / 停用(停用仅影响新分配与选择器,历史数据保留) */
export function DepartmentStatusTag({ status }: DepartmentStatusTagProps): ReactNode {
const active = status === 'active';
return (
<span className={active ? 'lx-status-tag is-active' : 'lx-status-tag is-disabled'}>
<span className="lx-status-tag__dot" aria-hidden="true" />
{active ? '启用' : '停用'}
</span>
);
}
@@ -0,0 +1,199 @@
/* eslint-disable max-lines-per-function -- 树面板包含根/子节点两种行与三态展示,集中便于对齐交互 */
import { Button, Skeleton, Switch } from '@douyinfe/semi-ui-19';
import type { DepartmentNode } from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import { IconAdd, IconChevronDown, IconChevronRight } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { childMemberCount, type DepartmentTreeModel } from './department-tree';
import { DepartmentStatusTag } from './DepartmentStatusTag';
export interface DepartmentTreePanelProps {
model: DepartmentTreeModel;
loading: boolean;
error: Error | null;
showDisabled: boolean;
canManage: boolean;
selectedId: string | null;
onSelect: (departmentId: string) => void;
onRetry: () => void;
onToggleShowDisabled: (checked: boolean) => void;
onCreate: () => void;
}
function DepartmentRow({
node,
selected,
depth,
expanded,
hasChildren,
onSelect,
onToggle,
}: {
node: DepartmentNode;
selected: boolean;
depth: 0 | 1;
expanded: boolean;
hasChildren: boolean;
onSelect: () => void;
onToggle: () => void;
}): ReactNode {
const disabled = node.status === 'disabled';
return (
<div
className={[
'lx-dept-row',
depth === 1 ? 'is-child' : '',
selected ? 'is-selected' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ')}
>
{depth === 0 && hasChildren ? (
<button
type="button"
className="lx-dept-row__caret"
aria-label={expanded ? '收起下级小组' : '展开下级小组'}
aria-expanded={expanded}
onClick={onToggle}
>
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</button>
) : (
<span className="lx-dept-row__caret lx-dept-row__caret--placeholder" aria-hidden="true" />
)}
<button type="button" className="lx-dept-row__main" onClick={onSelect}>
<span className="lx-dept-row__name">
<b>{node.name}</b>
<DepartmentStatusTag status={node.status} />
</span>
<span className="lx-dept-row__meta">负责人 {node.leader?.name ?? '未指定'}</span>
</button>
<span className="lx-dept-row__count" title={`直属 ${String(node.directMemberCount)} 人`}>
{depth === 0 && childMemberCount(node) > 0
? `${String(node.memberCount)} 人`
: `${String(node.directMemberCount)} 人`}
</span>
</div>
);
}
function TreeBody({
props,
}: {
props: Pick<
DepartmentTreePanelProps,
'model' | 'selectedId' | 'onSelect' | 'canManage' | 'onCreate' | 'onRetry' | 'error' | 'loading'
>;
}): ReactNode {
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
if (props.loading) {
return (
<div className="lx-dept-tree__state">
<Skeleton placeholder={<Skeleton.Paragraph rows={7} />} loading />
</div>
);
}
if (props.error !== null) {
return (
<ErrorState
className="lx-dept-tree__state"
title="部门加载失败"
message={props.error.message}
onRetry={props.onRetry}
/>
);
}
if (props.model.roots.length === 0) {
return (
<PageEmpty
title="暂无部门"
description="创建一级部门后再维护下属小组。"
action={
props.canManage ? (
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
onClick={props.onCreate}
>
新建部门
</Button>
) : undefined
}
/>
);
}
return (
<ul className="lx-dept-tree__list">
{props.model.roots.map((root) => {
const isExpanded = !collapsed.has(root.node.id);
return (
<li key={root.node.id}>
<DepartmentRow
node={root.node}
depth={0}
selected={root.node.id === props.selectedId}
expanded={isExpanded}
hasChildren={root.children.length > 0}
onSelect={() => {
props.onSelect(root.node.id);
}}
onToggle={() => {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(root.node.id)) {
next.delete(root.node.id);
} else {
next.add(root.node.id);
}
return next;
});
}}
/>
{isExpanded && root.children.length > 0 ? (
<ul className="lx-dept-tree__children">
{root.children.map((child) => (
<li key={child.id}>
<DepartmentRow
node={child}
depth={1}
selected={child.id === props.selectedId}
expanded={false}
hasChildren={false}
onSelect={() => {
props.onSelect(child.id);
}}
onToggle={() => undefined}
/>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
);
}
/** 左侧组织树:两级结构、人数、负责人、状态与「显示已停用」开关 */
export function DepartmentTreePanel(props: DepartmentTreePanelProps): ReactNode {
return (
<section className="lx-dept-tree" aria-label="组织架构">
<header className="lx-dept-tree__head">
<span className="lx-dept-tree__title">组织架构</span>
<span className="lx-dept-tree__count">{props.model.nodes.length} 个</span>
<label className="lx-dept-tree__toggle">
<Switch size="small" checked={props.showDisabled} onChange={props.onToggleShowDisabled} />
显示已停用
</label>
</header>
<div className="lx-dept-tree__body">
<TreeBody props={props} />
</div>
</section>
);
}
@@ -0,0 +1,303 @@
/* eslint-disable max-lines-per-function, complexity -- 部门管理页负责树选中、内联编辑与三类弹窗编排,集中便于追踪数据流 */
import { Button } from '@douyinfe/semi-ui-19';
import { type CreateDepartmentInput, type DepartmentNode, Permission } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { IconAdd } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Can } from '@/components/Can';
import {
useCreateDepartmentMutation,
useDepartmentsQuery,
useDisableDepartmentMutation,
useEnableDepartmentMutation,
useUpdateDepartmentMutation,
} from '@/hooks/queries/use-directory';
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
import { usePermission } from '@/hooks/use-permission';
import { CreateDepartmentDialog, DepartmentStatusDialog } from './department-dialogs';
import {
type DepartmentFormErrors,
type DepartmentFormValues,
departmentFormValues,
EMPTY_DEPARTMENT_FORM,
toUpdateDepartmentInput,
} from './department-form';
import {
buildDepartmentTree,
departmentDisableBlocker,
departmentEnableBlocker,
filterTreeByStatus,
} from './department-tree';
import { DepartmentDetailPanel } from './DepartmentDetailPanel';
import { DepartmentTreePanel } from './DepartmentTreePanel';
type CreateParent = DepartmentNode | null | undefined;
interface StatusDialogState {
mode: 'disable' | 'enable';
node: DepartmentNode;
}
function DepartmentsHeader({ onCreate }: { onCreate: () => void }): ReactNode {
return (
<header className="lx-depts__header">
<div>
<h1>部门管理</h1>
<p>维护两级组织架构、负责人、排序与停用状态;部门只停用不删除。</p>
</div>
<Can permission={Permission.USER_MANAGE}>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
data-testid="dept-create"
onClick={onCreate}
>
新建部门
</Button>
</Can>
</header>
);
}
/** 部门管理页:左侧两级组织树 + 右侧详情/内联编辑(/system/departments) */
export function DepartmentsPage(): ReactNode {
const canManage = usePermission(Permission.USER_MANAGE);
const [searchParams, setSearchParams] = useSearchParams();
const [showDisabled, setShowDisabled] = useState(true);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState<DepartmentFormValues>(EMPTY_DEPARTMENT_FORM);
const [formErrors, setFormErrors] = useState<DepartmentFormErrors>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [createParent, setCreateParent] = useState<CreateParent>(undefined);
const [statusDialog, setStatusDialog] = useState<StatusDialogState | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const departmentsQuery = useDepartmentsQuery(true);
const candidates = useUserCandidatesQuery(canManage);
const createMutation = useCreateDepartmentMutation();
const updateMutation = useUpdateDepartmentMutation();
const disableMutation = useDisableDepartmentMutation();
const enableMutation = useEnableDepartmentMutation();
const model = useMemo(
() => buildDepartmentTree(departmentsQuery.data ?? []),
[departmentsQuery.data],
);
const visibleModel = useMemo(
() => filterTreeByStatus(model, showDisabled),
[model, showDisabled],
);
const requestedId = searchParams.get('id');
const selectedId =
requestedId !== null && visibleModel.byId.has(requestedId)
? requestedId
: (visibleModel.roots[0]?.node.id ?? visibleModel.nodes[0]?.id ?? null);
const selectedNode = selectedId === null ? null : (visibleModel.byId.get(selectedId) ?? null);
const selectDepartment = (departmentId: string): void => {
const next = new URLSearchParams(searchParams);
next.set('id', departmentId);
setSearchParams(next, { replace: true });
setEditing(false);
setSaveError(null);
};
const openCreate = (parent: DepartmentNode | null): void => {
setActionError(null);
setCreateParent(parent);
setCreateOpen(true);
};
const submitCreate = (input: CreateDepartmentInput): void => {
createMutation.mutate(input, {
onSuccess: (id) => {
setCreateOpen(false);
setCreateParent(undefined);
notifySuccess('部门已创建');
selectDepartment(id);
},
onError: (error) => {
setActionError(error.message);
},
});
};
const submitStatus = (): void => {
if (statusDialog === null) {
return;
}
const { mode, node } = statusDialog;
const onSuccess = (): void => {
setStatusDialog(null);
notifySuccess(mode === 'disable' ? '部门已停用' : '部门已启用');
if (mode === 'disable' && !showDisabled) {
setShowDisabled(true);
}
};
const onError = (error: Error): void => {
setActionError(error.message);
};
if (mode === 'disable') {
disableMutation.mutate(node.id, { onSuccess, onError });
return;
}
enableMutation.mutate(node.id, { onSuccess, onError });
};
const startEdit = (): void => {
if (selectedNode === null) {
return;
}
setForm(departmentFormValues(selectedNode));
setFormErrors({});
setSaveError(null);
setEditing(true);
};
const saveEdit = (): void => {
if (selectedNode === null) {
return;
}
const result = toUpdateDepartmentInput(form);
if (result.input === null) {
setFormErrors(result.errors);
return;
}
updateMutation.mutate(
{ departmentId: selectedNode.id, input: result.input },
{
onSuccess: () => {
setEditing(false);
notifySuccess('部门已更新');
},
onError: (error) => {
setSaveError(error.message);
},
},
);
};
const openStatus = (mode: 'disable' | 'enable', node: DepartmentNode): void => {
setActionError(null);
setStatusDialog({ mode, node });
};
const createDialogParent = createOpen ? (createParent ?? null) : null;
return (
<div className="lx-depts">
<DepartmentsHeader
onCreate={() => {
openCreate(null);
}}
/>
<div className="lx-depts__split">
<DepartmentTreePanel
model={visibleModel}
loading={departmentsQuery.isPending}
error={departmentsQuery.error}
showDisabled={showDisabled}
canManage={canManage}
selectedId={selectedId}
onSelect={selectDepartment}
onRetry={() => {
void departmentsQuery.refetch();
}}
onToggleShowDisabled={setShowDisabled}
onCreate={() => {
openCreate(null);
}}
/>
{departmentsQuery.isPending ? (
<div className="lx-dept-detail lx-dept-detail--loading">
<div className="lx-dept-detail__body">
<p className="lx-dept-detail__muted">部门加载中…</p>
</div>
</div>
) : (
<DepartmentDetailPanel
node={selectedNode}
model={visibleModel}
canManage={canManage}
editing={editing}
form={form}
errors={formErrors}
saving={updateMutation.isPending}
saveError={saveError}
leaderCandidates={candidates.data?.items ?? []}
onStartEdit={startEdit}
onCancelEdit={() => {
setEditing(false);
setSaveError(null);
}}
onChangeField={(field, value) => {
setForm((current) => ({ ...current, [field]: value }));
setFormErrors((current) => ({ ...current, [field]: undefined }));
}}
onSave={saveEdit}
onDisable={() => {
if (selectedNode !== null) {
openStatus('disable', selectedNode);
}
}}
onEnable={() => {
if (selectedNode !== null) {
openStatus('enable', selectedNode);
}
}}
onCreateChild={() => {
if (selectedNode !== null) {
openCreate(selectedNode);
}
}}
onSelect={selectDepartment}
/>
)}
</div>
{createOpen ? (
<CreateDepartmentDialog
parent={createDialogParent}
leaderCandidates={candidates.data?.items ?? []}
loading={createMutation.isPending}
error={actionError}
onCancel={() => {
setCreateOpen(false);
setCreateParent(undefined);
}}
onConfirm={submitCreate}
/>
) : null}
{statusDialog === null ? null : (
<DepartmentStatusDialog
mode={statusDialog.mode}
node={statusDialog.node}
blocker={
statusDialog.mode === 'disable'
? departmentDisableBlocker(
statusDialog.node,
visibleModel.nodes.filter((node) => node.parentId === statusDialog.node.id),
)
: departmentEnableBlocker(
statusDialog.node,
statusDialog.node.parentId === null
? null
: (visibleModel.byId.get(statusDialog.node.parentId) ?? null),
)
}
loading={disableMutation.isPending || enableMutation.isPending}
error={actionError}
onCancel={() => {
setStatusDialog(null);
}}
onConfirm={submitStatus}
/>
)}
</div>
);
}
@@ -0,0 +1,230 @@
/* eslint-disable max-lines-per-function -- 新建与停用/启用两类弹窗按字段与后果顺序展开,集中便于对齐交互 */
import { Button, Checkbox, Input, InputNumber, Modal, Select } from '@douyinfe/semi-ui-19';
import type { CreateDepartmentInput, DepartmentNode, UserSummary } from '@lingxi/shared';
import { IconAlert } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import {
type DepartmentFormErrors,
type DepartmentFormValues,
EMPTY_DEPARTMENT_FORM,
toCreateDepartmentInput,
} from './department-form';
import { DepartmentStatusTag } from './DepartmentStatusTag';
export interface CreateDepartmentDialogProps {
parent: DepartmentNode | null;
leaderCandidates: readonly UserSummary[];
loading: boolean;
error: string | null;
onCancel: () => void;
onConfirm: (input: CreateDepartmentInput) => void;
}
/** 新建部门 / 小组:上级只读(由页面上下文决定),负责人仅取启用用户 */
export function CreateDepartmentDialog({
parent,
leaderCandidates,
loading,
error,
onCancel,
onConfirm,
}: CreateDepartmentDialogProps): ReactNode {
const [form, setForm] = useState<DepartmentFormValues>(EMPTY_DEPARTMENT_FORM);
const [errors, setErrors] = useState<DepartmentFormErrors>({});
const setField = (field: keyof DepartmentFormValues, value: string): void => {
setForm((current) => ({ ...current, [field]: value }));
setErrors((current) => ({ ...current, [field]: undefined }));
};
const submit = (): void => {
const result = toCreateDepartmentInput(form, parent?.id ?? null);
if (result.input === null) {
setErrors(result.errors);
return;
}
onConfirm(result.input);
};
return (
<Modal
visible
title={parent === null ? '新建一级部门' : '新建下级小组'}
width={480}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button theme="solid" type="primary" loading={loading} onClick={submit}>
创建
</Button>
</>
}
>
<div className="lx-dept-dialog">
<p className="lx-dept-dialog__context">
上级:<b>{parent?.name ?? '无(一级部门)'}</b>
</p>
<label className="lx-field">
<span className="lx-field__label">
部门名称
<span className="lx-field__required">*</span>
</span>
<Input
value={form.name}
maxLength={20}
placeholder="请输入部门或小组名称"
onChange={(value) => {
setField('name', value);
}}
/>
{errors.name === undefined ? null : (
<span className="lx-field__error">{errors.name}</span>
)}
</label>
<label className="lx-field">
<span className="lx-field__label">负责人</span>
<Select
value={form.leaderUserId === '' ? undefined : form.leaderUserId}
placeholder="未指定(可稍后设置)"
showClear
optionList={leaderCandidates.map((user) => ({
label: `${user.name} · ${user.account}`,
value: user.id,
}))}
onChange={(value) => {
setField('leaderUserId', typeof value === 'string' ? value : '');
}}
/>
</label>
<label className="lx-field">
<span className="lx-field__label">排序</span>
<InputNumber
value={form.sortOrder === '' ? undefined : Number(form.sortOrder)}
min={0}
max={9999}
placeholder="0(数字越小越靠前)"
onChange={(value) => {
setField('sortOrder', String(value));
}}
/>
{errors.sortOrder === undefined ? null : (
<span className="lx-field__error">{errors.sortOrder}</span>
)}
</label>
{error === null ? null : <p className="lx-field__error">{error}</p>}
</div>
</Modal>
);
}
export interface DepartmentStatusDialogProps {
mode: 'disable' | 'enable';
node: DepartmentNode;
blocker: string | null;
loading: boolean;
error: string | null;
onCancel: () => void;
onConfirm: () => void;
}
function statusConsequences(mode: 'disable' | 'enable', node: DepartmentNode): string[] {
if (mode === 'disable') {
return [
'停用后该部门不再出现在新建 / 编辑用户的部门选择中',
'历史数据与成员归属保留,可随时重新启用',
`当前含下级成员 ${String(node.memberCount)} 人、直属 ${String(node.directMemberCount)} 人`,
];
}
return ['启用后恢复出现在部门选择器中', '上级部门必须处于启用状态', '本次变更将写入审计日志'];
}
/** 停用 / 启用二次确认:对象卡 → 规则提示 → 勾选后才能提交;后端拒绝时展示服务端文案 */
export function DepartmentStatusDialog({
mode,
node,
blocker,
loading,
error,
onCancel,
onConfirm,
}: DepartmentStatusDialogProps): ReactNode {
const [acknowledged, setAcknowledged] = useState(false);
const disableMode = mode === 'disable';
return (
<Modal
visible
title={disableMode ? '停用部门' : '启用部门'}
width={520}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type={disableMode ? 'danger' : 'primary'}
loading={loading}
disabled={!acknowledged || blocker !== null}
onClick={onConfirm}
data-testid="dept-status-confirm"
>
{disableMode ? '停用部门' : '启用部门'}
</Button>
</>
}
>
<div className="lx-confirm-dialog">
<div className="lx-confirm-dialog__object">
<span className="lx-confirm-dialog__object-icon" aria-hidden="true">
{node.name.slice(0, 1)}
</span>
<span className="lx-confirm-dialog__object-copy">
<span className="lx-confirm-dialog__object-title">{node.name}</span>
<span className="lx-confirm-dialog__object-meta">
{node.parentId === null ? '一级部门' : '小组'} · 含下级 {node.memberCount} 人
</span>
</span>
<DepartmentStatusTag status={node.status} />
</div>
<div
className={disableMode ? 'lx-confirm-dialog__warn' : 'lx-confirm-dialog__warn is-info'}
>
<IconAlert size={16} />
<ul>
{statusConsequences(mode, node).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
{blocker === null ? null : (
<div className="lx-confirm-dialog__warn">
<IconAlert size={16} />
<div>
当前不可{mode === 'disable' ? '停用' : '启用'}:{blocker}
</div>
</div>
)}
{error === null ? null : <p className="lx-field__error">{error}</p>}
<Checkbox
className="lx-confirm-dialog__ack"
checked={acknowledged}
onChange={(event) => {
setAcknowledged(event.target.checked === true);
}}
>
我已了解上述影响,并确认{mode === 'disable' ? '停用' : '启用'}该部门
</Checkbox>
</div>
</Modal>
);
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import {
departmentFormValues,
EMPTY_DEPARTMENT_FORM,
toCreateDepartmentInput,
toUpdateDepartmentInput,
} from './department-form';
const PARENT_ID = '01HZZZZZZZZZZZZZZZZZZZZZ01';
const LEADER_ID = '01HZZZZZZZZZZZZZZZZZZZZZ02';
describe('部门表单校验', () => {
it('新建:名称必填、父级与负责人可选', () => {
const empty = toCreateDepartmentInput(EMPTY_DEPARTMENT_FORM, null);
expect(empty.input).toBeNull();
expect(empty.errors.name).toBeTruthy();
const valid = toCreateDepartmentInput(
{ name: ' 设计组 ', leaderUserId: LEADER_ID, sortOrder: '3' },
PARENT_ID,
);
expect(valid.errors).toEqual({});
expect(valid.input).toEqual({
name: '设计组',
parentId: PARENT_ID,
leaderUserId: LEADER_ID,
sortOrder: 3,
});
});
it('新建:名称超长与非法排序给出字段错误', () => {
const longName = toCreateDepartmentInput(
{ name: '一二三四五六七八九十一二三四五六七八九十一', leaderUserId: '', sortOrder: '' },
null,
);
expect(longName.input).toBeNull();
expect(longName.errors.name).toContain('20');
const badSort = toCreateDepartmentInput(
{ name: '设计组', leaderUserId: '', sortOrder: '-1' },
null,
);
expect(badSort.input).toBeNull();
expect(badSort.errors.sortOrder).toBeTruthy();
});
it('编辑:负责人清空提交 null,上级不可改', () => {
const result = toUpdateDepartmentInput({ name: '素材一组', leaderUserId: '', sortOrder: '' });
expect(result.input).toEqual({ name: '素材一组', leaderUserId: null });
expect(result.errors).toEqual({});
});
it('编辑:未填名称时仍以空字符串报错', () => {
const result = toUpdateDepartmentInput({ name: '', leaderUserId: '', sortOrder: '' });
expect(result.input).toBeNull();
expect(result.errors.name).toBeTruthy();
});
it('回显节点值(负责人 / 排序 / 名称)', () => {
expect(
departmentFormValues({
id: PARENT_ID,
name: '内容生产中心',
parentId: null,
leader: { id: LEADER_ID, name: '陈默' },
status: 'active',
sortOrder: 2,
memberCount: 19,
directMemberCount: 4,
createdAt: '2026-11-01T00:00:00.000Z',
}),
).toEqual({ name: '内容生产中心', leaderUserId: LEADER_ID, sortOrder: '2' });
expect(departmentFormValues(null)).toEqual(EMPTY_DEPARTMENT_FORM);
});
});
@@ -0,0 +1,114 @@
import {
type CreateDepartmentInput,
createDepartmentInputSchema,
type DepartmentNode,
type UpdateDepartmentInput,
updateDepartmentInputSchema,
} from '@lingxi/shared';
export interface DepartmentFormValues {
name: string;
/** 空字符串表示不指定负责人 */
leaderUserId: string;
/** 数字字符串,空字符串表示使用默认排序 0 */
sortOrder: string;
}
export type DepartmentFormField = 'name' | 'sortOrder';
export type DepartmentFormErrors = Partial<Record<DepartmentFormField, string>>;
export const EMPTY_DEPARTMENT_FORM: DepartmentFormValues = {
name: '',
leaderUserId: '',
sortOrder: '',
};
export function departmentFormValues(node?: DepartmentNode | null): DepartmentFormValues {
if (node === undefined || node === null) {
return EMPTY_DEPARTMENT_FORM;
}
return {
name: node.name,
leaderUserId: node.leader?.id ?? '',
sortOrder: String(node.sortOrder),
};
}
function parseSortOrder(value: string): number | undefined | 'invalid' {
const trimmed = value.trim();
if (trimmed === '') {
return undefined;
}
if (!/^\d+$/.test(trimmed)) {
return 'invalid';
}
return Number(trimmed);
}
function collectErrors(
kind: 'create' | 'update',
issues: readonly { path: (string | number)[]; message: string }[],
): DepartmentFormErrors {
const errors: DepartmentFormErrors = {};
for (const issue of issues) {
const field = issue.path[0];
if (field === 'name' || field === 'sortOrder') {
errors[field] ??= issue.message;
}
if (kind === 'create' && field === 'leaderUserId') {
errors.name ??= '负责人不合法,请重新选择';
}
}
return errors;
}
export interface CreateDepartmentFormResult {
input: CreateDepartmentInput | null;
errors: DepartmentFormErrors;
}
/** 新建部门 / 小组:与后端共用 createDepartmentInputSchema 校验 */
export function toCreateDepartmentInput(
values: DepartmentFormValues,
parentId: string | null,
): CreateDepartmentFormResult {
const sortOrder = parseSortOrder(values.sortOrder);
if (sortOrder === 'invalid') {
return { input: null, errors: { sortOrder: '排序必须是非负整数' } };
}
const candidate = {
name: values.name,
parentId,
...(values.leaderUserId === '' ? {} : { leaderUserId: values.leaderUserId }),
...(sortOrder === undefined ? {} : { sortOrder }),
};
const parsed = createDepartmentInputSchema.safeParse(candidate);
if (!parsed.success) {
return { input: null, errors: collectErrors('create', parsed.error.issues) };
}
return { input: parsed.data, errors: {} };
}
export interface UpdateDepartmentFormResult {
input: UpdateDepartmentInput | null;
errors: DepartmentFormErrors;
}
/** 编辑部门:上级关系创建后不可改,只提交名称 / 负责人 / 排序 */
export function toUpdateDepartmentInput(values: DepartmentFormValues): UpdateDepartmentFormResult {
const sortOrder = parseSortOrder(values.sortOrder);
if (sortOrder === 'invalid') {
return { input: null, errors: { sortOrder: '排序必须是非负整数' } };
}
const candidate = {
name: values.name,
leaderUserId: values.leaderUserId === '' ? null : values.leaderUserId,
...(sortOrder === undefined ? {} : { sortOrder }),
};
const parsed = updateDepartmentInputSchema.safeParse(candidate);
if (!parsed.success) {
return { input: null, errors: collectErrors('update', parsed.error.issues) };
}
return { input: parsed.data, errors: {} };
}
@@ -0,0 +1,113 @@
import type { DepartmentNode } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
buildDepartmentTree,
childMemberCount,
childrenOf,
departmentDisableBlocker,
departmentEnableBlocker,
filterTreeByStatus,
} from './department-tree';
const ROOT_A = '01HZZZZZZZZZZZZZZZZZZZZZ01';
const ROOT_B = '01HZZZZZZZZZZZZZZZZZZZZZ02';
const CHILD_A1 = '01HZZZZZZZZZZZZZZZZZZZZZ03';
const CHILD_A2 = '01HZZZZZZZZZZZZZZZZZZZZZ04';
const DISABLED_ROOT = '01HZZZZZZZZZZZZZZZZZZZZZ05';
function department(
id: string,
name: string,
parentId: string | null,
overrides: Partial<DepartmentNode> = {},
): DepartmentNode {
return {
id,
name,
parentId,
leader: null,
status: 'active',
sortOrder: 0,
memberCount: 0,
directMemberCount: 0,
createdAt: '2026-11-01T00:00:00.000Z',
...overrides,
};
}
const NODES: DepartmentNode[] = [
department(ROOT_A, '内容生产中心', null, { sortOrder: 1, memberCount: 19, directMemberCount: 4 }),
department(CHILD_A2, '设计组', ROOT_A, { sortOrder: 2, memberCount: 3, directMemberCount: 3 }),
department(CHILD_A1, '素材一组', ROOT_A, { sortOrder: 1, memberCount: 9, directMemberCount: 9 }),
department(ROOT_B, '交付中心', null, { sortOrder: 2, memberCount: 14, directMemberCount: 14 }),
department(DISABLED_ROOT, '历史部门', null, { status: 'disabled', sortOrder: 9 }),
];
describe('部门树构建', () => {
it('按 sortOrder、名称排序并挂载下级小组', () => {
const model = buildDepartmentTree(NODES);
expect(model.roots.map((root) => root.node.name)).toEqual([
'内容生产中心',
'交付中心',
'历史部门',
]);
expect(model.roots[0]?.children.map((child) => child.name)).toEqual(['素材一组', '设计组']);
expect(model.roots[1]?.children).toEqual([]);
expect(childrenOf(model, ROOT_A).map((child) => child.id)).toEqual([CHILD_A1, CHILD_A2]);
expect(model.byId.get(ROOT_A)?.name).toBe('内容生产中心');
});
it('关闭「显示已停用」时过滤停用部门', () => {
const model = buildDepartmentTree(NODES);
const visible = filterTreeByStatus(model, false);
expect(visible.roots.map((root) => root.node.name)).toEqual(['内容生产中心', '交付中心']);
expect(filterTreeByStatus(model, true).nodes).toHaveLength(5);
});
it('含下级人数 = 总人数 - 直属人数', () => {
const model = buildDepartmentTree(NODES);
const root = model.byId.get(ROOT_A);
expect(root === undefined ? null : childMemberCount(root)).toBe(15);
});
});
describe('停用 / 启用前置约束', () => {
const root = department(ROOT_A, '内容生产中心', null, {
memberCount: 19,
directMemberCount: 0,
});
const child = department(CHILD_A1, '素材一组', ROOT_A, {
memberCount: 9,
directMemberCount: 9,
});
it('有直属在职成员时禁止停用', () => {
expect(departmentDisableBlocker(child, [])).toContain('9 名在职成员');
});
it('一级部门存在启用中的下级小组时禁止停用', () => {
expect(departmentDisableBlocker(root, [department(CHILD_A1, '素材一组', ROOT_A)])).toContain(
'启用中的下级小组',
);
});
it('下级仍有在职成员时禁止停用', () => {
const stoppedChildren = [department(CHILD_A1, '素材一组', ROOT_A, { status: 'disabled' })];
expect(departmentDisableBlocker(root, stoppedChildren)).toContain('下级小组仍有');
});
it('空部门允许停用;已停用返回提示', () => {
const emptyRoot = { ...root, memberCount: 0, directMemberCount: 0 };
expect(departmentDisableBlocker(emptyRoot, [])).toBeNull();
expect(departmentDisableBlocker({ ...child, status: 'disabled' }, [])).toBe('部门已停用');
});
it('上级停用时禁止启用下级小组', () => {
const disabledChild = { ...child, status: 'disabled' as const };
const disabledParent = { ...root, status: 'disabled' as const };
expect(departmentEnableBlocker(disabledChild, disabledParent)).toContain('上级部门已停用');
expect(departmentEnableBlocker(disabledChild, root)).toBeNull();
expect(departmentEnableBlocker({ ...child, status: 'active' }, root)).toBe('部门已启用');
});
});
@@ -0,0 +1,109 @@
import type { DepartmentNode } from '@lingxi/shared';
export interface DepartmentTreeGroup {
node: DepartmentNode;
children: DepartmentNode[];
}
export interface DepartmentTreeModel {
roots: DepartmentTreeGroup[];
nodes: DepartmentNode[];
byId: Map<string, DepartmentNode>;
}
function compareDepartments(left: DepartmentNode, right: DepartmentNode): number {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.name.localeCompare(right.name, 'zh-Hans-CN');
}
/** 拍平列表 → 两级树(一级部门 + 直属小组),按 sortOrder、名称排序 */
export function buildDepartmentTree(nodes: readonly DepartmentNode[]): DepartmentTreeModel {
const sorted = [...nodes].sort(compareDepartments);
const byId = new Map(sorted.map((node) => [node.id, node]));
const childrenByParent = new Map<string, DepartmentNode[]>();
const roots: DepartmentTreeGroup[] = [];
for (const node of sorted) {
if (node.parentId === null) {
roots.push({ node, children: [] });
continue;
}
const children = childrenByParent.get(node.parentId) ?? [];
children.push(node);
childrenByParent.set(node.parentId, children);
}
for (const root of roots) {
root.children = childrenByParent.get(root.node.id) ?? [];
}
return { roots, nodes: sorted, byId };
}
export function childrenOf(model: DepartmentTreeModel, departmentId: string): DepartmentNode[] {
return model.nodes.filter((node) => node.parentId === departmentId);
}
/** 父级 + 自身名称链(详情「上级部门」展示;一级部门返回空数组) */
export function departmentPathNames(model: DepartmentTreeModel, departmentId: string): string[] {
const node = model.byId.get(departmentId);
if (node === undefined || node.parentId === null) {
return [];
}
const parent = model.byId.get(node.parentId);
return parent === undefined ? [] : [parent.name];
}
/** 过滤已停用部门(含其下级);管理页默认展示全部,开关关闭时只看启用 */
export function filterTreeByStatus(
model: DepartmentTreeModel,
showDisabled: boolean,
): DepartmentTreeModel {
if (showDisabled) {
return model;
}
return buildDepartmentTree(model.nodes.filter((node) => node.status === 'active'));
}
/** 下级小组在职成员数(含下级总人数 - 本部门直属人数) */
export function childMemberCount(node: DepartmentNode): number {
return Math.max(0, node.memberCount - node.directMemberCount);
}
/**
* 停用前置校验(与后端规则一致,用于按钮禁用与提示):
* 本部门有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时不可停用。
*/
export function departmentDisableBlocker(
node: DepartmentNode,
children: readonly DepartmentNode[],
): string | null {
if (node.status === 'disabled') {
return '部门已停用';
}
if (node.directMemberCount > 0) {
return `部门下仍有 ${String(node.directMemberCount)} 名在职成员,请先调整成员所属部门`;
}
const activeChildren = children.filter((child) => child.status === 'active');
if (activeChildren.length > 0) {
return `存在 ${String(activeChildren.length)} 个启用中的下级小组,请先停用下级小组`;
}
const nestedMembers = childMemberCount(node);
if (nestedMembers > 0) {
return `下级小组仍有 ${String(nestedMembers)} 名在职成员,请先调整成员所属部门`;
}
return null;
}
/** 启用前置校验:上级部门必须启用(小组不能挂到停用部门下) */
export function departmentEnableBlocker(
node: DepartmentNode,
parent: DepartmentNode | null,
): string | null {
if (node.status === 'active') {
return '部门已启用';
}
if (parent !== null && parent.status === 'disabled') {
return '上级部门已停用,请先启用上级部门';
}
return null;
}
@@ -22,7 +22,10 @@ const departments: DepartmentNode[] = [
name: '内容生产中心',
parentId: null,
leader: null,
status: 'active',
sortOrder: 1,
memberCount: 19,
directMemberCount: 4,
createdAt: '2026-09-01T00:00:00.000Z',
},
{
@@ -30,7 +33,10 @@ const departments: DepartmentNode[] = [
name: '素材一组',
parentId: ROOT_ID,
leader: null,
status: 'active',
sortOrder: 1,
memberCount: 9,
directMemberCount: 9,
createdAt: '2026-09-01T00:00:00.000Z',
},
{
@@ -38,7 +44,10 @@ const departments: DepartmentNode[] = [
name: '交付中心',
parentId: null,
leader: null,
status: 'active',
sortOrder: 2,
memberCount: 14,
directMemberCount: 14,
createdAt: '2026-09-01T00:00:00.000Z',
},
];
+13
View File
@@ -24,6 +24,11 @@ const UserNewPage = lazy(async () => {
return { default: module.UserNewPage };
});
const DepartmentsPage = lazy(async () => {
const module = await import('@/pages/system/departments/DepartmentsPage');
return { default: module.DepartmentsPage };
});
const RolesPage = lazy(async () => {
const module = await import('@/pages/system/roles/RolesPage');
return { default: module.RolesPage };
@@ -97,6 +102,14 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: 'system/departments',
element: (
<RequirePermission permission={Permission.USER_VIEW}>
{withSkeleton(<DepartmentsPage />)}
</RequirePermission>
),
},
{
path: 'system/roles',
element: (
+4
View File
@@ -31,6 +31,10 @@ describe('路由常量与登录回跳', () => {
);
});
it('部门管理路由挂在管理中心下', () => {
expect(ROUTES.system.departments).toBe('/system/departments');
});
it('sanitizeRedirect 只接受站内路径', () => {
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
+2
View File
@@ -12,6 +12,7 @@ export const ROUTES = {
users: '/system/users',
userNew: '/system/users/new',
userDetail: '/system/users/:id',
departments: '/system/departments',
roles: '/system/roles',
roleNew: '/system/roles/new',
roleDetail: '/system/roles/:id',
@@ -29,6 +30,7 @@ export type RoutePath =
| typeof ROUTES.system.users
| typeof ROUTES.system.userNew
| typeof ROUTES.system.userDetail
| typeof ROUTES.system.departments
| typeof ROUTES.system.roles
| typeof ROUTES.system.roleNew
| typeof ROUTES.system.roleDetail
+437
View File
@@ -0,0 +1,437 @@
/* 管理中心 · 部门管理(/system/departments):左组织树 + 右详情/编辑 */
.lx-depts {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: var(--lx-space-4);
}
.lx-depts__header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
}
.lx-depts__header h1 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
color: var(--lx-color-text-primary);
}
.lx-depts__header p {
margin-top: 2px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-depts__split {
display: flex;
min-height: 0;
flex: 1;
gap: var(--lx-space-4);
}
/* ---------- 左侧组织树 ---------- */
.lx-dept-tree {
display: flex;
width: 400px;
flex: 0 0 400px;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-dept-tree__head {
display: flex;
height: 48px;
flex: 0 0 48px;
align-items: center;
gap: var(--lx-space-2);
padding: 0 var(--lx-space-4);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-dept-tree__title {
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-dept-tree__count {
padding: 0 7px;
border-radius: var(--lx-radius-pill);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: 11px;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.lx-dept-tree__toggle {
display: inline-flex;
margin-left: auto;
align-items: center;
gap: 6px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-dept-tree__body {
min-height: 0;
flex: 1;
overflow-y: auto;
}
.lx-dept-tree__state {
padding: var(--lx-space-5);
}
.lx-dept-tree__list {
padding: 6px;
}
.lx-dept-tree__children {
padding-left: 14px;
}
.lx-dept-row {
display: flex;
min-height: 56px;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: var(--lx-radius-md);
}
.lx-dept-row:hover {
background: var(--lx-color-bg-hover);
}
.lx-dept-row.is-selected {
background: var(--lx-color-brand-soft);
box-shadow: inset 2px 0 0 var(--lx-color-brand);
}
.lx-dept-row.is-disabled .lx-dept-row__name b,
.lx-dept-row.is-disabled .lx-dept-row__count {
color: var(--lx-color-text-tertiary);
}
.lx-dept-row__caret {
display: inline-flex;
width: 22px;
height: 22px;
flex: 0 0 22px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-sm);
color: var(--lx-color-text-tertiary);
}
.lx-dept-row__caret:hover {
background: var(--lx-color-bg-active);
color: var(--lx-color-brand);
}
.lx-dept-row__caret--placeholder {
visibility: hidden;
}
.lx-dept-row__main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 4px 2px;
text-align: left;
}
.lx-dept-row__name {
display: inline-flex;
align-items: center;
gap: 6px;
}
.lx-dept-row__name b {
font-size: var(--lx-font-size-sm);
font-weight: 500;
color: var(--lx-color-text-primary);
}
.lx-dept-row.is-selected .lx-dept-row__name b {
color: var(--lx-color-brand);
}
.lx-dept-row__meta {
overflow: hidden;
max-width: 210px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-dept-row__count {
flex: 0 0 auto;
padding-right: 4px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* ---------- 右侧详情 ---------- */
.lx-dept-detail {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-dept-detail__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
.lx-dept-detail__head {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--lx-space-4);
padding: var(--lx-space-4) var(--lx-space-5);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-dept-detail__avatar {
display: inline-flex;
width: 44px;
height: 44px;
flex: 0 0 44px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-md);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: var(--lx-font-size-lg);
font-weight: 600;
}
.lx-dept-detail__ident {
min-width: 0;
flex: 1;
}
.lx-dept-detail__name {
display: inline-flex;
align-items: center;
gap: var(--lx-space-2);
}
.lx-dept-detail__name h2 {
font-size: var(--lx-font-size-lg);
font-weight: 600;
line-height: 24px;
color: var(--lx-color-text-primary);
}
.lx-dept-detail__meta {
margin-top: 2px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
.lx-dept-detail__actions {
display: flex;
flex: 0 0 auto;
gap: var(--lx-space-2);
}
.lx-dept-detail__readonly {
flex: 0 0 auto;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-dept-tag {
display: inline-flex;
height: 20px;
align-items: center;
padding: 0 6px;
border-radius: var(--lx-radius-sm);
background: var(--lx-color-bg-hover);
color: var(--lx-color-text-secondary);
font-size: 11px;
line-height: 20px;
}
.lx-dept-detail__body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: var(--lx-space-6);
padding: var(--lx-space-5);
overflow-y: auto;
}
.lx-dept-detail__section {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
}
.lx-dept-detail__section-head {
display: flex;
align-items: center;
gap: var(--lx-space-2);
}
.lx-dept-detail__section-head > .semi-button {
margin-left: auto;
}
.lx-dept-detail__section-title {
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-dept-notice {
padding: 9px 12px;
border-radius: var(--lx-radius-md);
background: color-mix(in srgb, var(--lx-color-warning) 10%, white);
color: color-mix(in srgb, var(--lx-color-warning) 78%, black);
font-size: var(--lx-font-size-xs);
line-height: 18px;
}
.lx-dept-detail__muted {
font-size: var(--lx-font-size-xs);
line-height: 18px;
color: var(--lx-color-text-tertiary);
}
.lx-dept-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lx-space-3) var(--lx-space-6);
}
.lx-dept-children {
display: flex;
flex-direction: column;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
overflow: hidden;
}
.lx-dept-children li + li .lx-dept-child-row {
border-top: 1px solid var(--lx-color-border);
}
.lx-dept-child-row {
display: flex;
width: 100%;
align-items: center;
gap: var(--lx-space-3);
padding: 10px var(--lx-space-4);
text-align: left;
}
.lx-dept-child-row:hover {
background: var(--lx-color-bg-hover);
}
.lx-dept-child-row__name {
display: inline-flex;
align-items: center;
gap: var(--lx-space-2);
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
}
.lx-dept-child-row__meta {
margin-left: auto;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.lx-dept-edit__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lx-space-4);
}
.lx-dept-edit__actions {
display: flex;
justify-content: flex-end;
gap: var(--lx-space-2);
}
.lx-dept-dialog {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
}
.lx-dept-dialog__context {
padding: 8px 12px;
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-subtle);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-dept-dialog__context b {
color: var(--lx-color-text-primary);
font-weight: 500;
}
.lx-dept-detail--loading {
justify-content: center;
}
/* ---------- 响应式:1280 / 1024 ---------- */
@media (width <= 1280px) {
.lx-dept-tree {
width: 340px;
flex-basis: 340px;
}
}
@media (width <= 1080px) {
.lx-dept-tree {
width: 300px;
flex-basis: 300px;
}
.lx-dept-row__meta {
max-width: 160px;
}
.lx-dept-kv-grid,
.lx-dept-edit__grid {
grid-template-columns: minmax(0, 1fr);
}
}
+62 -5
View File
@@ -226,8 +226,22 @@
},
"/api/v1/departments/tree": {
"get": {
"description": "memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true",
"operationId": "DepartmentsController_tree",
"parameters": [],
"parameters": [
{
"name": "include_disabled",
"required": false,
"in": "query",
"schema": {
"enum": [
"true",
"false"
],
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
@@ -238,7 +252,7 @@
"bearer": []
}
],
"summary": "部门树(两级,含成员数与负责人)",
"summary": "部门树(两级)",
"tags": [
"identity-departments"
]
@@ -246,7 +260,7 @@
},
"/api/v1/departments": {
"post": {
"description": "最多两级,在小组下新建返回 42201",
"description": "最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903",
"operationId": "DepartmentsController_create",
"parameters": [],
"responses": {
@@ -267,7 +281,8 @@
},
"/api/v1/departments/{id}": {
"patch": {
"operationId": "DepartmentsController_rename",
"description": "上级关系创建后不可移动;负责人必须是启用用户",
"operationId": "DepartmentsController_update",
"parameters": [],
"responses": {
"200": {
@@ -279,7 +294,49 @@
"bearer": []
}
],
"summary": "部门改名",
"summary": "更新部门(名称 / 负责人 / 排序)",
"tags": [
"identity-departments"
]
}
},
"/api/v1/departments/{id}/disable": {
"post": {
"description": "本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903",
"operationId": "DepartmentsController_disable",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "停用部门",
"tags": [
"identity-departments"
]
}
},
"/api/v1/departments/{id}/enable": {
"post": {
"description": "上级必须启用;已启用返回 40903",
"operationId": "DepartmentsController_enable",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "启用部门",
"tags": [
"identity-departments"
]
+32 -1
View File
@@ -4,6 +4,7 @@ import { ulid } from '../ulid.js';
import {
accountSchema,
changePasswordInputSchema,
createDepartmentInputSchema,
createUserInputSchema,
DATA_SCOPE_TYPES,
dataScopeSchema,
@@ -22,6 +23,7 @@ import {
roleSummarySchema,
tokenPairSchema,
transferCustomersInputSchema,
updateDepartmentInputSchema,
updateUserInputSchema,
USER_LIST_SORTS,
USER_LIST_STATUSES,
@@ -183,7 +185,10 @@ describe('departmentNodeSchema', () => {
name: '运营中心',
parentId: null,
leader: null,
status: 'active',
sortOrder: 0,
memberCount: 0,
directMemberCount: 0,
createdAt: ISO,
});
expect(parsed.parentId).toBeNull();
@@ -196,19 +201,25 @@ describe('departmentNodeSchema', () => {
name: '投放一组',
parentId: ulid(),
leader: { id: ulid(), name: '王五' },
status: 'active',
sortOrder: 1,
memberCount: 12,
directMemberCount: 12,
createdAt: ISO,
});
expect(parsed.leader?.name).toBe('王五');
});
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名', () => {
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名、非法状态', () => {
const base = {
id: ulid(),
name: '运营中心',
parentId: null,
leader: null,
status: 'active',
sortOrder: 0,
memberCount: 3,
directMemberCount: 3,
createdAt: ISO,
};
expect(() => departmentNodeSchema.parse({ ...base, memberCount: -1 })).toThrow();
@@ -217,6 +228,26 @@ describe('departmentNodeSchema', () => {
expect(() =>
departmentNodeSchema.parse({ ...base, leader: { id: ulid(), name: '' } }),
).toThrow();
expect(() => departmentNodeSchema.parse({ ...base, status: 'offline' })).toThrow();
});
});
describe('部门写接口契约', () => {
it('新建部门:名称必填、parentId 可空、排序与负责人可选', () => {
const parsed = createDepartmentInputSchema.parse({ name: '运营中心' });
expect(parsed.parentId).toBeNull();
expect(() => createDepartmentInputSchema.parse({ name: '' })).toThrow();
expect(() =>
createDepartmentInputSchema.parse({ name: '运营中心', parentId: 'bad' }),
).toThrow();
expect(() => createDepartmentInputSchema.parse({ name: '运营中心', unknown: true })).toThrow();
});
it('更新部门:只允许名称 / 负责人 / 排序,未知字段拒绝', () => {
const parsed = updateDepartmentInputSchema.parse({ name: '运营中心', sortOrder: 2 });
expect(parsed.sortOrder).toBe(2);
expect(() => updateDepartmentInputSchema.parse({ parentId: ulid() })).toThrow();
expect(() => updateDepartmentInputSchema.parse({ sortOrder: -1 })).toThrow();
});
});
+56 -1
View File
@@ -82,7 +82,26 @@ export type RoleSummary = z.infer<typeof roleSummarySchema>;
// 部门
// ---------------------------------------------------------------------------
/** 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH) */
/** 部门状态:停用后不出现在新建 / 编辑用户的部门选择中,历史数据保留 */
export const DEPARTMENT_STATUSES = ['active', 'disabled'] as const;
export const departmentStatusSchema = z.enum(DEPARTMENT_STATUSES, {
errorMap: () => ({ message: '部门状态只能是 active 或 disabled' }),
});
export type DepartmentStatus = z.infer<typeof departmentStatusSchema>;
/** 部门名称:1–20 个字符(同级唯一由服务端校验) */
export const departmentNameSchema = z
.string()
.trim()
.min(1, '请输入部门名称')
.max(20, '部门名称最多 20 个字符');
/**
* 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH)。
* memberCount 含下级成员,directMemberCount 仅本部门直属成员。
*/
export const departmentNodeSchema = z.object({
id: ulidSchema,
name: z.string().min(1, '部门名称不能为空'),
@@ -93,12 +112,48 @@ export const departmentNodeSchema = z.object({
name: z.string().min(1, '负责人姓名不能为空'),
})
.nullable(),
status: departmentStatusSchema,
sortOrder: z.number().int('排序必须是整数').min(0, '排序不能为负数'),
memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'),
directMemberCount: z.number().int('直属人数必须是整数').nonnegative('直属人数不能为负数'),
createdAt: isoDateTimeSchema,
});
export type DepartmentNode = z.infer<typeof departmentNodeSchema>;
/** 新建部门 / 小组:parentId 为 null 建一级部门,否则在该部门下建小组 */
export const createDepartmentInputSchema = z
.object({
name: departmentNameSchema,
parentId: ulidSchema.nullable().default(null),
leaderUserId: ulidSchema.nullable().optional(),
sortOrder: z.number().int().min(0).max(9999).optional(),
})
.strict();
export type CreateDepartmentInput = z.infer<typeof createDepartmentInputSchema>;
/** 更新部门:只允许改名称 / 负责人 / 排序;上级关系创建后不可移动 */
export const updateDepartmentInputSchema = z
.object({
name: departmentNameSchema.optional(),
leaderUserId: ulidSchema.nullable().optional(),
sortOrder: z.number().int().min(0).max(9999).optional(),
})
.strict();
export type UpdateDepartmentInput = z.infer<typeof updateDepartmentInputSchema>;
/** 部门树查询:管理页需要包含已停用部门,普通选择器默认只看启用 */
export const departmentTreeQuerySchema = z.object({
include_disabled: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
});
export type DepartmentTreeQuery = z.infer<typeof departmentTreeQuerySchema>;
// ---------------------------------------------------------------------------
// 用户
// ---------------------------------------------------------------------------