背景:
- 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:
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 { ErrorCode } from '@lingxi/shared';
|
||||||
import { type PipeTransform } from '@nestjs/common';
|
import { type PipeTransform } from '@nestjs/common';
|
||||||
import type { ZodSchema } from 'zod';
|
import type { ZodType, ZodTypeDef } from 'zod';
|
||||||
|
|
||||||
import { AppException } from '../errors/app-exception';
|
import { AppException } from '../errors/app-exception';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { AppException } from '../errors/app-exception';
|
|||||||
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
|
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
|
||||||
*/
|
*/
|
||||||
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
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 {
|
transform(value: unknown): T {
|
||||||
const result = this.schema.safeParse(value);
|
const result = this.schema.safeParse(value);
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
import { Permission, ulidSchema } from '@lingxi/shared';
|
import {
|
||||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
type CreateDepartmentInput,
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
createDepartmentInputSchema,
|
||||||
import { z } from 'zod';
|
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 { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||||
import { DepartmentsService, type DepartmentTreeNode } from './departments.service';
|
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')
|
@ApiTags('identity-departments')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('departments')
|
@Controller('departments')
|
||||||
@@ -31,30 +24,70 @@ export class DepartmentsController {
|
|||||||
|
|
||||||
@Get('tree')
|
@Get('tree')
|
||||||
@RequirePermissions(Permission.USER_VIEW)
|
@RequirePermissions(Permission.USER_VIEW)
|
||||||
@ApiOperation({ summary: '部门树(两级,含成员数与负责人)' })
|
@ApiOperation({
|
||||||
async tree(): Promise<{ items: DepartmentTreeNode[] }> {
|
summary: '部门树(两级)',
|
||||||
return { items: await this.departments.tree() };
|
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()
|
@Post()
|
||||||
@RequirePermissions(Permission.USER_MANAGE)
|
@RequirePermissions(Permission.USER_MANAGE)
|
||||||
@Audit('identity.department.create')
|
@Audit('identity.department.create')
|
||||||
@ApiOperation({ summary: '新建部门 / 小组', description: '最多两级,在小组下新建返回 42201' })
|
@ApiOperation({
|
||||||
|
summary: '新建部门 / 小组',
|
||||||
|
description: '最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903',
|
||||||
|
})
|
||||||
async create(
|
async create(
|
||||||
@Body(new ZodValidationPipe(createDepartmentSchema)) input: CreateDepartmentBody,
|
@Body(new ZodValidationPipe(createDepartmentInputSchema)) input: CreateDepartmentInput,
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
return this.departments.create(input);
|
return this.departments.create(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RequirePermissions(Permission.USER_MANAGE)
|
@RequirePermissions(Permission.USER_MANAGE)
|
||||||
@Audit('identity.department.rename')
|
@Audit('identity.department.update')
|
||||||
@ApiOperation({ summary: '部门改名' })
|
@ApiOperation({
|
||||||
async rename(
|
summary: '更新部门(名称 / 负责人 / 排序)',
|
||||||
|
description: '上级关系创建后不可移动;负责人必须是启用用户',
|
||||||
|
})
|
||||||
|
async update(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body(new ZodValidationPipe(updateDepartmentSchema)) input: UpdateDepartmentBody,
|
@Body(new ZodValidationPipe(updateDepartmentInputSchema)) input: UpdateDepartmentInput,
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
await this.departments.rename(id, input.name);
|
await this.departments.update(id, input);
|
||||||
return { id };
|
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 { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
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 type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
|
||||||
|
/** 带负责人的部门行(树查询 select 结果) */
|
||||||
|
type DepartmentRow = Prisma.DepartmentGetPayload<{
|
||||||
|
include: { leader: { select: { id: true; name: true } } };
|
||||||
|
}>;
|
||||||
|
|
||||||
/** 部门树节点:契约节点 + 子节点(最多两级) */
|
/** 部门树节点:契约节点 + 子节点(最多两级) */
|
||||||
export interface DepartmentTreeNode extends DepartmentNode {
|
export interface DepartmentTreeNode extends DepartmentNode {
|
||||||
children: DepartmentTreeNode[];
|
children: DepartmentTreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateDepartmentPayload {
|
/** 状态在 DB 中用大写枚举值(与 users/roles 一致),API 契约用小写 */
|
||||||
name: string;
|
const DB_ACTIVE = 'ACTIVE';
|
||||||
parentId: string | null;
|
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 {
|
function codeOf(name: string): string {
|
||||||
@@ -27,19 +43,20 @@ function codeOf(name: string): string {
|
|||||||
|
|
||||||
function mapDepartmentError(error: unknown): AppException {
|
function mapDepartmentError(error: unknown): AppException {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
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: '部门保存失败' });
|
return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组织架构:两级部门树(含成员数与负责人)、新建、改名 */
|
/** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DepartmentsService {
|
export class DepartmentsService {
|
||||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async tree(): Promise<DepartmentTreeNode[]> {
|
async tree(includeDisabled: boolean): Promise<DepartmentTreeNode[]> {
|
||||||
const [departments, counts] = await Promise.all([
|
const [departments, counts, lineage] = await Promise.all([
|
||||||
this.prisma.department.findMany({
|
this.prisma.department.findMany({
|
||||||
|
where: includeDisabled ? {} : { status: DB_ACTIVE },
|
||||||
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
include: { leader: { select: { id: true, name: true } } },
|
include: { leader: { select: { id: true, name: true } } },
|
||||||
}),
|
}),
|
||||||
@@ -48,16 +65,30 @@ export class DepartmentsService {
|
|||||||
where: { status: 'ACTIVE' },
|
where: { status: 'ACTIVE' },
|
||||||
_count: { _all: true },
|
_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]));
|
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>();
|
const nodes = new Map<string, DepartmentTreeNode>();
|
||||||
for (const department of departments) {
|
for (const department of departments) {
|
||||||
|
const direct = byDept.get(department.id) ?? 0;
|
||||||
nodes.set(department.id, {
|
nodes.set(department.id, {
|
||||||
id: department.id,
|
id: department.id,
|
||||||
name: department.name,
|
name: department.name,
|
||||||
parentId: department.parentId,
|
parentId: department.parentId,
|
||||||
leader: department.leader,
|
leader: department.leader,
|
||||||
memberCount: byDept.get(department.id) ?? 0,
|
status: toApiStatus(department.status),
|
||||||
|
sortOrder: department.sortOrder,
|
||||||
|
memberCount: direct,
|
||||||
|
directMemberCount: direct,
|
||||||
createdAt: department.createdAt.toISOString(),
|
createdAt: department.createdAt.toISOString(),
|
||||||
children: [],
|
children: [],
|
||||||
});
|
});
|
||||||
@@ -68,25 +99,36 @@ export class DepartmentsService {
|
|||||||
roots.push(node);
|
roots.push(node);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parent = nodes.get(node.parentId);
|
nodes.get(node.parentId)?.children.push(node);
|
||||||
if (parent !== undefined) {
|
}
|
||||||
parent.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) {
|
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;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreateDepartmentPayload): Promise<{ id: string }> {
|
async create(input: CreateDepartmentInput): Promise<{ id: string }> {
|
||||||
await this.assertParentUsable(input.parentId);
|
await this.assertParentUsable(input.parentId);
|
||||||
|
await this.assertLeaderUsable(input.leaderUserId);
|
||||||
|
await this.assertNameFree(input.name, input.parentId, null);
|
||||||
try {
|
try {
|
||||||
const created = await this.prisma.department.create({
|
const created = await this.prisma.department.create({
|
||||||
data: {
|
data: {
|
||||||
id: ulid(),
|
id: ulid(),
|
||||||
name: input.name,
|
name: input.name,
|
||||||
parentId: input.parentId,
|
parentId: input.parentId,
|
||||||
|
leaderUserId: input.leaderUserId ?? null,
|
||||||
|
sortOrder: input.sortOrder ?? 0,
|
||||||
|
status: DB_ACTIVE,
|
||||||
code: codeOf(input.name),
|
code: codeOf(input.name),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -97,26 +139,85 @@ export class DepartmentsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async rename(id: string, name: string): Promise<void> {
|
async update(id: string, input: UpdateDepartmentInput): Promise<void> {
|
||||||
const existing = await this.prisma.department.findUnique({ where: { id } });
|
const existing = await this.findOrThrow(id);
|
||||||
if (existing === null) {
|
if (input.leaderUserId !== undefined) {
|
||||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
|
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 {
|
try {
|
||||||
await this.prisma.department.update({ where: { id }, data: { name } });
|
await this.prisma.department.update({ where: { id }, data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapDepartmentError(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> {
|
private async assertParentUsable(parentId: string | null): Promise<void> {
|
||||||
if (parentId === null) {
|
if (parentId === null) {
|
||||||
return;
|
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) {
|
if (parent === null) {
|
||||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' });
|
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) {
|
if (parent.parentId !== null) {
|
||||||
throw new AppException({
|
throw new AppException({
|
||||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
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', () => {
|
describe('DepartmentsService', () => {
|
||||||
it('部门树:两级嵌套且一级成员数含下级', async () => {
|
function serviceOf(prisma: Record<string, unknown>): DepartmentsService {
|
||||||
const departments = [
|
return new DepartmentsService(prisma as unknown as PrismaService);
|
||||||
{
|
}
|
||||||
|
|
||||||
|
function departmentRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
id: 'd1',
|
id: 'd1',
|
||||||
code: 'content',
|
code: 'content',
|
||||||
name: '内容生产中心',
|
name: '内容生产中心',
|
||||||
parentId: null,
|
parentId: null,
|
||||||
leaderUserId: null,
|
leaderUserId: null,
|
||||||
sortOrder: 1,
|
sortOrder: 0,
|
||||||
status: 'ACTIVE',
|
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
leader: { id: 'u1', name: '陈默' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'g1',
|
|
||||||
code: 'content-g1',
|
|
||||||
name: '素材一组',
|
|
||||||
parentId: 'd1',
|
|
||||||
leaderUserId: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
leader: null,
|
leader: null,
|
||||||
},
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('部门树:默认只返回启用部门;memberCount 含下级、directMemberCount 仅直属', async () => {
|
||||||
|
const rows = [
|
||||||
|
departmentRow({ id: 'd1', name: '内容生产中心' }),
|
||||||
|
departmentRow({ id: 'g1', name: '素材一组', parentId: 'd1' }),
|
||||||
];
|
];
|
||||||
const service = new DepartmentsService({
|
const findMany = vi.fn().mockResolvedValue(rows);
|
||||||
department: { findMany: vi.fn().mockResolvedValue(departments) },
|
const service = serviceOf({
|
||||||
user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) },
|
department: { findMany },
|
||||||
} as unknown as PrismaService);
|
user: {
|
||||||
const tree = await service.tree();
|
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).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]?.name).toBe('素材一组');
|
||||||
|
expect(tree[0]?.children[0]?.memberCount).toBe(5);
|
||||||
|
expect(tree[0]?.status).toBe('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('新建:父级不存在 40001,超过两级 42201', async () => {
|
it('部门树:include_disabled=true 时包含停用部门且状态映射为 disabled', async () => {
|
||||||
const service = new DepartmentsService({
|
const service = serviceOf({
|
||||||
department: {
|
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(
|
await expectRejected(
|
||||||
service.create({ name: '新组', parentId: 'missing' }),
|
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).create({
|
||||||
|
name: '新部门',
|
||||||
|
parentId: 'missing',
|
||||||
|
}),
|
||||||
ErrorCode.PARAM_INVALID,
|
ErrorCode.PARAM_INVALID,
|
||||||
);
|
);
|
||||||
|
|
||||||
const service2 = new DepartmentsService({
|
|
||||||
department: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
|
||||||
id: 'g1',
|
|
||||||
parentId: 'd1',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
} as unknown as PrismaService);
|
|
||||||
await expectRejected(
|
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,
|
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('重命名不存在的部门返回 40401', async () => {
|
it('新建:同级重名 40903,成功后落库 sortOrder / status / 负责人', async () => {
|
||||||
const service = new DepartmentsService({
|
const duplicated = serviceOf({
|
||||||
department: { findUnique: vi.fn().mockResolvedValue(null) },
|
department: { findFirst: vi.fn().mockResolvedValue({ id: 'other' }) },
|
||||||
} as unknown as PrismaService);
|
});
|
||||||
await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND);
|
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' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -219,10 +219,19 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureDepartment(departmentId: string): Promise<void> {
|
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) {
|
if (department === null) {
|
||||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' });
|
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> {
|
private async ensureRoles(roleIds: string[]): Promise<void> {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,12 @@ export const ENDPOINTS = {
|
|||||||
userTransferCustomers: (userId: string): string =>
|
userTransferCustomers: (userId: string): string =>
|
||||||
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
|
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
|
||||||
departments: '/departments/tree',
|
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',
|
roles: '/roles',
|
||||||
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
||||||
permissions: '/permissions',
|
permissions: '/permissions',
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ describe('identity 接口封装', () => {
|
|||||||
name: '内容生产中心',
|
name: '内容生产中心',
|
||||||
parentId: null,
|
parentId: null,
|
||||||
leader: null,
|
leader: null,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 1,
|
||||||
memberCount: 19,
|
memberCount: 19,
|
||||||
|
directMemberCount: 4,
|
||||||
createdAt: '2026-09-01T00:00:00.000Z',
|
createdAt: '2026-09-01T00:00:00.000Z',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -118,7 +121,10 @@ describe('identity 接口封装', () => {
|
|||||||
name: '素材一组',
|
name: '素材一组',
|
||||||
parentId: GROUP_ID,
|
parentId: GROUP_ID,
|
||||||
leader: null,
|
leader: null,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 1,
|
||||||
memberCount: 9,
|
memberCount: 9,
|
||||||
|
directMemberCount: 9,
|
||||||
createdAt: '2026-09-01T00:00:00.000Z',
|
createdAt: '2026-09-01T00:00:00.000Z',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type ChangePasswordInput,
|
type ChangePasswordInput,
|
||||||
changePasswordInputSchema,
|
changePasswordInputSchema,
|
||||||
|
type CreateDepartmentInput,
|
||||||
|
createDepartmentInputSchema,
|
||||||
type CreateUserInput,
|
type CreateUserInput,
|
||||||
createUserInputSchema,
|
createUserInputSchema,
|
||||||
type DepartmentNode,
|
type DepartmentNode,
|
||||||
@@ -17,6 +19,8 @@ import {
|
|||||||
roleSummarySchema,
|
roleSummarySchema,
|
||||||
type TransferCustomersInput,
|
type TransferCustomersInput,
|
||||||
transferCustomersInputSchema,
|
transferCustomersInputSchema,
|
||||||
|
type UpdateDepartmentInput,
|
||||||
|
updateDepartmentInputSchema,
|
||||||
type UpdateUserInput,
|
type UpdateUserInput,
|
||||||
updateUserInputSchema,
|
updateUserInputSchema,
|
||||||
} from '@lingxi/shared';
|
} 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 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 校验 */
|
/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */
|
||||||
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
|
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
|
||||||
@@ -69,9 +73,13 @@ export async function fetchMe(): Promise<MeResponse> {
|
|||||||
return meResponseSchema.parse(payload);
|
return meResponseSchema.parse(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */
|
/**
|
||||||
export async function fetchDepartments(): Promise<DepartmentNode[]> {
|
* 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用。
|
||||||
const payload: unknown = await request(ENDPOINTS.departments);
|
* 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;
|
const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload;
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -93,6 +101,32 @@ export async function fetchDepartments(): Promise<DepartmentNode[]> {
|
|||||||
return flat;
|
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[]> {
|
export async function fetchRoles(): Promise<RoleSummary[]> {
|
||||||
const payload: unknown = await request(ENDPOINTS.roles);
|
const payload: unknown = await request(ENDPOINTS.roles);
|
||||||
return parseList(payload, roleSummarySchema);
|
return parseList(payload, roleSummarySchema);
|
||||||
@@ -102,7 +136,7 @@ export async function fetchRoles(): Promise<RoleSummary[]> {
|
|||||||
export async function createUser(input: CreateUserInput): Promise<string | null> {
|
export async function createUser(input: CreateUserInput): Promise<string | null> {
|
||||||
const body = createUserInputSchema.parse(input);
|
const body = createUserInputSchema.parse(input);
|
||||||
const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body });
|
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;
|
return parsed.success ? parsed.data.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const queryKeys = {
|
|||||||
userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
|
userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
|
||||||
user: (userId: string) => ['users', 'detail', userId] as const,
|
user: (userId: string) => ['users', 'detail', userId] as const,
|
||||||
userCustomers: (userId: string) => ['users', 'customers', 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,
|
roles: ['roles'] as const,
|
||||||
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
||||||
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { type DepartmentNode, type RoleSummary } from '@lingxi/shared';
|
import type {
|
||||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
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';
|
import { queryKeys } from '@/api/query-keys';
|
||||||
|
|
||||||
export function useDepartmentsQuery(): UseQueryResult<DepartmentNode[]> {
|
/** 部门树:默认只取启用部门(用户页选择器);部门管理页传 includeDisabled=true */
|
||||||
|
export function useDepartmentsQuery(includeDisabled = false): UseQueryResult<DepartmentNode[]> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.departments,
|
queryKey: queryKeys.departments(includeDisabled),
|
||||||
queryFn: fetchDepartments,
|
queryFn: () => fetchDepartments(includeDisabled),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -19,3 +38,63 @@ export function useRolesQuery(): UseQueryResult<RoleSummary[]> {
|
|||||||
staleTime: 5 * 60_000,
|
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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,9 +11,13 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
|
|||||||
import { SideNav } from './SideNav';
|
import { SideNav } from './SideNav';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
|
|
||||||
/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */
|
/** 用户与组织、部门管理为「满高主从布局」,由页面自己控制内部滚动 */
|
||||||
function isFlushRoute(pathname: string): boolean {
|
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 {
|
export function AppLayout(): ReactNode {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '@/styles/pages.css';
|
|||||||
import '@/styles/users.css';
|
import '@/styles/users.css';
|
||||||
import '@/styles/roles.css';
|
import '@/styles/roles.css';
|
||||||
import '@/styles/role-editor.css';
|
import '@/styles/role-editor.css';
|
||||||
|
import '@/styles/departments.css';
|
||||||
|
|
||||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||||
import { localeZhCN } from '@lingxi/ui';
|
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('按路径定位当前模块与菜单', () => {
|
it('按路径定位当前模块与菜单', () => {
|
||||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||||
expect(active?.module.key).toBe('material');
|
expect(active?.module.key).toBe('material');
|
||||||
@@ -55,5 +64,7 @@ describe('导航配置', () => {
|
|||||||
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
|
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
|
||||||
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/users');
|
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/users');
|
||||||
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '用户与组织']);
|
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '用户与组织']);
|
||||||
|
const deptBreadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/departments');
|
||||||
|
expect(deptBreadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '部门管理']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Permission, type PermissionCode } from '@lingxi/shared';
|
import { Permission, type PermissionCode } from '@lingxi/shared';
|
||||||
import {
|
import {
|
||||||
|
IconApps,
|
||||||
IconAsset,
|
IconAsset,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -254,6 +255,14 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
permission: Permission.USER_VIEW,
|
permission: Permission.USER_VIEW,
|
||||||
description: '账号、组织与登录状态',
|
description: '账号、组织与登录状态',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'departments',
|
||||||
|
label: '部门管理',
|
||||||
|
path: '/system/departments',
|
||||||
|
icon: IconApps,
|
||||||
|
permission: Permission.USER_VIEW,
|
||||||
|
description: '两级组织架构、负责人、排序与停用状态',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'roles',
|
key: 'roles',
|
||||||
label: '角色管理',
|
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: '内容生产中心',
|
name: '内容生产中心',
|
||||||
parentId: null,
|
parentId: null,
|
||||||
leader: null,
|
leader: null,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 1,
|
||||||
memberCount: 19,
|
memberCount: 19,
|
||||||
|
directMemberCount: 4,
|
||||||
createdAt: '2026-09-01T00:00:00.000Z',
|
createdAt: '2026-09-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -30,7 +33,10 @@ const departments: DepartmentNode[] = [
|
|||||||
name: '素材一组',
|
name: '素材一组',
|
||||||
parentId: ROOT_ID,
|
parentId: ROOT_ID,
|
||||||
leader: null,
|
leader: null,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 1,
|
||||||
memberCount: 9,
|
memberCount: 9,
|
||||||
|
directMemberCount: 9,
|
||||||
createdAt: '2026-09-01T00:00:00.000Z',
|
createdAt: '2026-09-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -38,7 +44,10 @@ const departments: DepartmentNode[] = [
|
|||||||
name: '交付中心',
|
name: '交付中心',
|
||||||
parentId: null,
|
parentId: null,
|
||||||
leader: null,
|
leader: null,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 2,
|
||||||
memberCount: 14,
|
memberCount: 14,
|
||||||
|
directMemberCount: 14,
|
||||||
createdAt: '2026-09-01T00:00:00.000Z',
|
createdAt: '2026-09-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const UserNewPage = lazy(async () => {
|
|||||||
return { default: module.UserNewPage };
|
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 RolesPage = lazy(async () => {
|
||||||
const module = await import('@/pages/system/roles/RolesPage');
|
const module = await import('@/pages/system/roles/RolesPage');
|
||||||
return { default: module.RolesPage };
|
return { default: module.RolesPage };
|
||||||
@@ -97,6 +102,14 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'system/departments',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.USER_VIEW}>
|
||||||
|
{withSkeleton(<DepartmentsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'system/roles',
|
path: 'system/roles',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ describe('路由常量与登录回跳', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('部门管理路由挂在管理中心下', () => {
|
||||||
|
expect(ROUTES.system.departments).toBe('/system/departments');
|
||||||
|
});
|
||||||
|
|
||||||
it('sanitizeRedirect 只接受站内路径', () => {
|
it('sanitizeRedirect 只接受站内路径', () => {
|
||||||
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
|
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
|
||||||
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
|
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const ROUTES = {
|
|||||||
users: '/system/users',
|
users: '/system/users',
|
||||||
userNew: '/system/users/new',
|
userNew: '/system/users/new',
|
||||||
userDetail: '/system/users/:id',
|
userDetail: '/system/users/:id',
|
||||||
|
departments: '/system/departments',
|
||||||
roles: '/system/roles',
|
roles: '/system/roles',
|
||||||
roleNew: '/system/roles/new',
|
roleNew: '/system/roles/new',
|
||||||
roleDetail: '/system/roles/:id',
|
roleDetail: '/system/roles/:id',
|
||||||
@@ -29,6 +30,7 @@ export type RoutePath =
|
|||||||
| typeof ROUTES.system.users
|
| typeof ROUTES.system.users
|
||||||
| typeof ROUTES.system.userNew
|
| typeof ROUTES.system.userNew
|
||||||
| typeof ROUTES.system.userDetail
|
| typeof ROUTES.system.userDetail
|
||||||
|
| typeof ROUTES.system.departments
|
||||||
| typeof ROUTES.system.roles
|
| typeof ROUTES.system.roles
|
||||||
| typeof ROUTES.system.roleNew
|
| typeof ROUTES.system.roleNew
|
||||||
| typeof ROUTES.system.roleDetail
|
| typeof ROUTES.system.roleDetail
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,8 +226,22 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/departments/tree": {
|
"/api/v1/departments/tree": {
|
||||||
"get": {
|
"get": {
|
||||||
|
"description": "memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true",
|
||||||
"operationId": "DepartmentsController_tree",
|
"operationId": "DepartmentsController_tree",
|
||||||
"parameters": [],
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "include_disabled",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"enum": [
|
||||||
|
"true",
|
||||||
|
"false"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": ""
|
"description": ""
|
||||||
@@ -238,7 +252,7 @@
|
|||||||
"bearer": []
|
"bearer": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "部门树(两级,含成员数与负责人)",
|
"summary": "部门树(两级)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"identity-departments"
|
"identity-departments"
|
||||||
]
|
]
|
||||||
@@ -246,7 +260,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/departments": {
|
"/api/v1/departments": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "最多两级,在小组下新建返回 42201",
|
"description": "最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903",
|
||||||
"operationId": "DepartmentsController_create",
|
"operationId": "DepartmentsController_create",
|
||||||
"parameters": [],
|
"parameters": [],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -267,7 +281,8 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/departments/{id}": {
|
"/api/v1/departments/{id}": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "DepartmentsController_rename",
|
"description": "上级关系创建后不可移动;负责人必须是启用用户",
|
||||||
|
"operationId": "DepartmentsController_update",
|
||||||
"parameters": [],
|
"parameters": [],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -279,7 +294,49 @@
|
|||||||
"bearer": []
|
"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": [
|
"tags": [
|
||||||
"identity-departments"
|
"identity-departments"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ulid } from '../ulid.js';
|
|||||||
import {
|
import {
|
||||||
accountSchema,
|
accountSchema,
|
||||||
changePasswordInputSchema,
|
changePasswordInputSchema,
|
||||||
|
createDepartmentInputSchema,
|
||||||
createUserInputSchema,
|
createUserInputSchema,
|
||||||
DATA_SCOPE_TYPES,
|
DATA_SCOPE_TYPES,
|
||||||
dataScopeSchema,
|
dataScopeSchema,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
roleSummarySchema,
|
roleSummarySchema,
|
||||||
tokenPairSchema,
|
tokenPairSchema,
|
||||||
transferCustomersInputSchema,
|
transferCustomersInputSchema,
|
||||||
|
updateDepartmentInputSchema,
|
||||||
updateUserInputSchema,
|
updateUserInputSchema,
|
||||||
USER_LIST_SORTS,
|
USER_LIST_SORTS,
|
||||||
USER_LIST_STATUSES,
|
USER_LIST_STATUSES,
|
||||||
@@ -183,7 +185,10 @@ describe('departmentNodeSchema', () => {
|
|||||||
name: '运营中心',
|
name: '运营中心',
|
||||||
parentId: null,
|
parentId: null,
|
||||||
leader: null,
|
leader: null,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 0,
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
|
directMemberCount: 0,
|
||||||
createdAt: ISO,
|
createdAt: ISO,
|
||||||
});
|
});
|
||||||
expect(parsed.parentId).toBeNull();
|
expect(parsed.parentId).toBeNull();
|
||||||
@@ -196,19 +201,25 @@ describe('departmentNodeSchema', () => {
|
|||||||
name: '投放一组',
|
name: '投放一组',
|
||||||
parentId: ulid(),
|
parentId: ulid(),
|
||||||
leader: { id: ulid(), name: '王五' },
|
leader: { id: ulid(), name: '王五' },
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 1,
|
||||||
memberCount: 12,
|
memberCount: 12,
|
||||||
|
directMemberCount: 12,
|
||||||
createdAt: ISO,
|
createdAt: ISO,
|
||||||
});
|
});
|
||||||
expect(parsed.leader?.name).toBe('王五');
|
expect(parsed.leader?.name).toBe('王五');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名', () => {
|
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名、非法状态', () => {
|
||||||
const base = {
|
const base = {
|
||||||
id: ulid(),
|
id: ulid(),
|
||||||
name: '运营中心',
|
name: '运营中心',
|
||||||
parentId: null,
|
parentId: null,
|
||||||
leader: null,
|
leader: null,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 0,
|
||||||
memberCount: 3,
|
memberCount: 3,
|
||||||
|
directMemberCount: 3,
|
||||||
createdAt: ISO,
|
createdAt: ISO,
|
||||||
};
|
};
|
||||||
expect(() => departmentNodeSchema.parse({ ...base, memberCount: -1 })).toThrow();
|
expect(() => departmentNodeSchema.parse({ ...base, memberCount: -1 })).toThrow();
|
||||||
@@ -217,6 +228,26 @@ describe('departmentNodeSchema', () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
departmentNodeSchema.parse({ ...base, leader: { id: ulid(), name: '' } }),
|
departmentNodeSchema.parse({ ...base, leader: { id: ulid(), name: '' } }),
|
||||||
).toThrow();
|
).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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
export const departmentNodeSchema = z.object({
|
||||||
id: ulidSchema,
|
id: ulidSchema,
|
||||||
name: z.string().min(1, '部门名称不能为空'),
|
name: z.string().min(1, '部门名称不能为空'),
|
||||||
@@ -93,12 +112,48 @@ export const departmentNodeSchema = z.object({
|
|||||||
name: z.string().min(1, '负责人姓名不能为空'),
|
name: z.string().min(1, '负责人姓名不能为空'),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
status: departmentStatusSchema,
|
||||||
|
sortOrder: z.number().int('排序必须是整数').min(0, '排序不能为负数'),
|
||||||
memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'),
|
memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'),
|
||||||
|
directMemberCount: z.number().int('直属人数必须是整数').nonnegative('直属人数不能为负数'),
|
||||||
createdAt: isoDateTimeSchema,
|
createdAt: isoDateTimeSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DepartmentNode = z.infer<typeof departmentNodeSchema>;
|
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>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 用户
|
// 用户
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user