背景:
- 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 { type PipeTransform } from '@nestjs/common';
|
||||
import type { ZodSchema } from 'zod';
|
||||
import type { ZodType, ZodTypeDef } from 'zod';
|
||||
|
||||
import { AppException } from '../errors/app-exception';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AppException } from '../errors/app-exception';
|
||||
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
|
||||
*/
|
||||
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
||||
constructor(private readonly schema: ZodSchema<T>) {}
|
||||
constructor(private readonly schema: ZodType<T, ZodTypeDef, unknown>) {}
|
||||
|
||||
transform(value: unknown): T {
|
||||
const result = this.schema.safeParse(value);
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { Permission, ulidSchema } from '@lingxi/shared';
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
type CreateDepartmentInput,
|
||||
createDepartmentInputSchema,
|
||||
type DepartmentNode,
|
||||
type DepartmentTreeQuery,
|
||||
departmentTreeQuerySchema,
|
||||
Permission,
|
||||
type UpdateDepartmentInput,
|
||||
updateDepartmentInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||
import { DepartmentsService, type DepartmentTreeNode } from './departments.service';
|
||||
|
||||
const createDepartmentSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符'),
|
||||
parentId: ulidSchema.nullable().default(null),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type CreateDepartmentBody = z.infer<typeof createDepartmentSchema>;
|
||||
|
||||
const updateDepartmentSchema = z
|
||||
.object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') })
|
||||
.strict();
|
||||
|
||||
type UpdateDepartmentBody = z.infer<typeof updateDepartmentSchema>;
|
||||
|
||||
/** 组织架构:两级部门树(A1 前端只读;写接口先备好,供后续批次使用) */
|
||||
/** 组织架构:两级部门树 + 新建 / 更新 / 停用 / 启用(不提供删除) */
|
||||
@ApiTags('identity-departments')
|
||||
@ApiBearerAuth()
|
||||
@Controller('departments')
|
||||
@@ -31,30 +24,70 @@ export class DepartmentsController {
|
||||
|
||||
@Get('tree')
|
||||
@RequirePermissions(Permission.USER_VIEW)
|
||||
@ApiOperation({ summary: '部门树(两级,含成员数与负责人)' })
|
||||
async tree(): Promise<{ items: DepartmentTreeNode[] }> {
|
||||
return { items: await this.departments.tree() };
|
||||
@ApiOperation({
|
||||
summary: '部门树(两级)',
|
||||
description:
|
||||
'memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true',
|
||||
})
|
||||
@ApiQuery({ name: 'include_disabled', required: false, enum: ['true', 'false'] })
|
||||
async tree(
|
||||
@Query(new ZodValidationPipe(departmentTreeQuerySchema)) query: DepartmentTreeQuery,
|
||||
): Promise<{ items: DepartmentTreeNode[] }> {
|
||||
return { items: await this.departments.tree(query.include_disabled) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.department.create')
|
||||
@ApiOperation({ summary: '新建部门 / 小组', description: '最多两级,在小组下新建返回 42201' })
|
||||
@ApiOperation({
|
||||
summary: '新建部门 / 小组',
|
||||
description: '最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903',
|
||||
})
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createDepartmentSchema)) input: CreateDepartmentBody,
|
||||
@Body(new ZodValidationPipe(createDepartmentInputSchema)) input: CreateDepartmentInput,
|
||||
): Promise<{ id: string }> {
|
||||
return this.departments.create(input);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.department.rename')
|
||||
@ApiOperation({ summary: '部门改名' })
|
||||
async rename(
|
||||
@Audit('identity.department.update')
|
||||
@ApiOperation({
|
||||
summary: '更新部门(名称 / 负责人 / 排序)',
|
||||
description: '上级关系创建后不可移动;负责人必须是启用用户',
|
||||
})
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateDepartmentSchema)) input: UpdateDepartmentBody,
|
||||
@Body(new ZodValidationPipe(updateDepartmentInputSchema)) input: UpdateDepartmentInput,
|
||||
): Promise<{ id: string }> {
|
||||
await this.departments.rename(id, input.name);
|
||||
await this.departments.update(id, input);
|
||||
return { id };
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.department.disable')
|
||||
@ApiOperation({
|
||||
summary: '停用部门',
|
||||
description:
|
||||
'本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903',
|
||||
})
|
||||
async disable(
|
||||
@Param('id') id: string,
|
||||
): Promise<{ id: string; status: DepartmentNode['status'] }> {
|
||||
return this.departments.disable(id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.department.enable')
|
||||
@ApiOperation({
|
||||
summary: '启用部门',
|
||||
description: '上级必须启用;已启用返回 40903',
|
||||
})
|
||||
async enable(@Param('id') id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
|
||||
return this.departments.enable(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||
import { loadDotenv } from '../../config/load-dotenv';
|
||||
|
||||
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
|
||||
const PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||
const PASSWORD = 'lingxi2026';
|
||||
const API = API_PREFIX;
|
||||
const SUFFIX = String(Date.now()).slice(-6);
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
status: 'active' | 'disabled';
|
||||
sortOrder: number;
|
||||
memberCount: number;
|
||||
directMemberCount: number;
|
||||
leader: { id: string; name: string } | null;
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function departmentName(label: string): string {
|
||||
return `测试${label}${SUFFIX}`;
|
||||
}
|
||||
|
||||
async function api(
|
||||
app: CreatedApiApp['app'],
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
return { status: response.statusCode, body: response.json() };
|
||||
}
|
||||
|
||||
describe('departments 集成(真实 PG)', () => {
|
||||
let created: CreatedApiApp;
|
||||
let token = '';
|
||||
let managerId = '';
|
||||
let rootId = '';
|
||||
let childId = '';
|
||||
let siblingId = '';
|
||||
let disabledRootId = '';
|
||||
|
||||
async function createDept(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
const response = await api(created.app, 'POST', '/departments', token, {
|
||||
name,
|
||||
parentId,
|
||||
...extra,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
return (response.body as { id: string }).id;
|
||||
}
|
||||
|
||||
async function tree(includeDisabled: boolean): Promise<Node[]> {
|
||||
const response = await api(
|
||||
created.app,
|
||||
'GET',
|
||||
`/departments/tree?include_disabled=${String(includeDisabled)}`,
|
||||
token,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
return (response.body as { items: Node[] }).items;
|
||||
}
|
||||
|
||||
function findNode(nodes: Node[], id: string): Node | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
const found = findNode(node.children, id);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
loadDotenv();
|
||||
created = await createApp();
|
||||
await created.app.init();
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
|
||||
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
|
||||
|
||||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'tech' } });
|
||||
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'platform_admin' } });
|
||||
managerId = ulid();
|
||||
const account = `it.dep.${SUFFIX}`;
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: managerId,
|
||||
name: '部门集成测试',
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: `136${String(Date.now()).slice(-8)}`,
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId: role.id }] },
|
||||
},
|
||||
});
|
||||
const login = await created.app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/login`,
|
||||
payload: { account, password: PASSWORD },
|
||||
});
|
||||
expect(login.statusCode).toBe(200);
|
||||
token = login.json<{ accessToken: string }>().accessToken;
|
||||
|
||||
rootId = await createDept(departmentName('一级部门'), null);
|
||||
childId = await createDept(departmentName('小组A'), rootId);
|
||||
siblingId = await createDept(departmentName('小组B'), rootId);
|
||||
|
||||
const disabledRoot = await prisma.department.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
code: `test-disabled-${SUFFIX}`,
|
||||
name: departmentName('停用部门'),
|
||||
status: 'DISABLED',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
disabledRootId = disabledRoot.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
|
||||
// 只清理本测试创建的部门(先子后父),避免误删种子 / 其他用例的数据
|
||||
await prisma.department.deleteMany({
|
||||
where: { name: { startsWith: '测试' }, parentId: { not: null } },
|
||||
});
|
||||
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
|
||||
await prisma.$disconnect();
|
||||
await created.app.close();
|
||||
});
|
||||
|
||||
it('部门树:默认过滤停用,include_disabled=true 返回停用节点与新增字段', async () => {
|
||||
const activeTree = await tree(false);
|
||||
expect(findNode(activeTree, disabledRootId)).toBeUndefined();
|
||||
const root = findNode(activeTree, rootId);
|
||||
expect(root).toBeDefined();
|
||||
expect(root?.status).toBe('active');
|
||||
expect(root?.sortOrder).toBe(0);
|
||||
expect(root?.directMemberCount).toBe(0);
|
||||
expect(root?.memberCount).toBe(0);
|
||||
expect(root?.children.map((child) => child.id).sort()).toEqual([childId, siblingId].sort());
|
||||
|
||||
const fullTree = await tree(true);
|
||||
expect(findNode(fullTree, disabledRootId)?.status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('新建:两级可建、超两级 42201、同级重名 40903、负责人非法 42201', async () => {
|
||||
const tooDeep = await api(created.app, 'POST', '/departments', token, {
|
||||
name: departmentName('三级组'),
|
||||
parentId: childId,
|
||||
});
|
||||
expect(tooDeep.status).toBe(422);
|
||||
expect((tooDeep.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||
|
||||
const duplicated = await api(created.app, 'POST', '/departments', token, {
|
||||
name: departmentName('小组A'),
|
||||
parentId: rootId,
|
||||
});
|
||||
expect(duplicated.status).toBe(409);
|
||||
expect((duplicated.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const badLeader = await api(created.app, 'POST', '/departments', token, {
|
||||
name: departmentName('小组C'),
|
||||
parentId: rootId,
|
||||
leaderUserId: ulid(),
|
||||
});
|
||||
expect(badLeader.status).toBe(422);
|
||||
expect((badLeader.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||
});
|
||||
|
||||
it('更新:名称 / 排序 / 负责人生效,重名 40903', async () => {
|
||||
const renamed = departmentName('小组A改');
|
||||
const response = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
|
||||
name: renamed,
|
||||
sortOrder: 3,
|
||||
leaderUserId: managerId,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const root = findNode(await tree(false), rootId);
|
||||
const child = root?.children.find((item) => item.id === childId);
|
||||
expect(child?.name).toBe(renamed);
|
||||
expect(child?.sortOrder).toBe(3);
|
||||
expect(child?.leader?.id).toBe(managerId);
|
||||
|
||||
const conflict = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
|
||||
name: departmentName('小组B'),
|
||||
});
|
||||
expect(conflict.status).toBe(409);
|
||||
});
|
||||
|
||||
it('停用:有在职成员 42201;存在启用子部门 42201;全部停用后可停用一级部门', async () => {
|
||||
const member = await prisma.user.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
name: '临时成员',
|
||||
account: `it.dep.member.${SUFFIX}`,
|
||||
email: `it.dep.member.${SUFFIX}@lingxi.cn`,
|
||||
phone: `138${String(Date.now()).slice(-8)}`,
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: childId,
|
||||
mustChangePassword: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const blockedByMember = await api(
|
||||
created.app,
|
||||
'POST',
|
||||
`/departments/${childId}/disable`,
|
||||
token,
|
||||
);
|
||||
expect(blockedByMember.status).toBe(422);
|
||||
expect((blockedByMember.body as ErrorBody).message).toContain('在职成员');
|
||||
|
||||
const blockedByChild = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
|
||||
expect(blockedByChild.status).toBe(422);
|
||||
expect((blockedByChild.body as ErrorBody).message).toMatch(/下级小组|在职成员/);
|
||||
|
||||
await prisma.user.delete({ where: { id: member.id } });
|
||||
const disabledChild = await api(created.app, 'POST', `/departments/${childId}/disable`, token);
|
||||
expect(disabledChild.status).toBe(200);
|
||||
expect((disabledChild.body as { status: string }).status).toBe('disabled');
|
||||
expect(findNode(await tree(false), childId)).toBeUndefined();
|
||||
|
||||
const disabledSibling = await api(
|
||||
created.app,
|
||||
'POST',
|
||||
`/departments/${siblingId}/disable`,
|
||||
token,
|
||||
);
|
||||
expect(disabledSibling.status).toBe(200);
|
||||
|
||||
const disabledRoot = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
|
||||
expect(disabledRoot.status).toBe(200);
|
||||
|
||||
const again = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
|
||||
expect(again.status).toBe(409);
|
||||
expect((again.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
});
|
||||
|
||||
it('启用:上级停用 42201;启用后上级可启用,子部门可再启用', async () => {
|
||||
const childBlocked = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
|
||||
expect(childBlocked.status).toBe(422);
|
||||
expect((childBlocked.body as ErrorBody).message).toContain('上级部门');
|
||||
|
||||
const rootEnabled = await api(created.app, 'POST', `/departments/${rootId}/enable`, token);
|
||||
expect(rootEnabled.status).toBe(200);
|
||||
|
||||
const childEnabled = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
|
||||
expect(childEnabled.status).toBe(200);
|
||||
expect(findNode(await tree(false), childId)?.status).toBe('active');
|
||||
|
||||
const again = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
|
||||
expect(again.status).toBe(409);
|
||||
});
|
||||
|
||||
it('不提供删除接口(DELETE 返回 404)', async () => {
|
||||
const response = await api(created.app, 'DELETE', `/departments/${childId}`, token);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type DepartmentNode, ErrorCode, ulid } from '@lingxi/shared';
|
||||
import {
|
||||
type CreateDepartmentInput,
|
||||
type DepartmentNode,
|
||||
ErrorCode,
|
||||
ulid,
|
||||
type UpdateDepartmentInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@@ -6,14 +12,24 @@ import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
|
||||
/** 带负责人的部门行(树查询 select 结果) */
|
||||
type DepartmentRow = Prisma.DepartmentGetPayload<{
|
||||
include: { leader: { select: { id: true; name: true } } };
|
||||
}>;
|
||||
|
||||
/** 部门树节点:契约节点 + 子节点(最多两级) */
|
||||
export interface DepartmentTreeNode extends DepartmentNode {
|
||||
children: DepartmentTreeNode[];
|
||||
}
|
||||
|
||||
export interface CreateDepartmentPayload {
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
/** 状态在 DB 中用大写枚举值(与 users/roles 一致),API 契约用小写 */
|
||||
const DB_ACTIVE = 'ACTIVE';
|
||||
const DB_DISABLED = 'DISABLED';
|
||||
|
||||
const DUPLICATE_NAME_MESSAGE = '同级部门名称已存在';
|
||||
|
||||
function toApiStatus(status: string): DepartmentNode['status'] {
|
||||
return status === DB_ACTIVE ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
function codeOf(name: string): string {
|
||||
@@ -27,19 +43,20 @@ function codeOf(name: string): string {
|
||||
|
||||
function mapDepartmentError(error: unknown): AppException {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '同级部门名称已存在' });
|
||||
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: DUPLICATE_NAME_MESSAGE });
|
||||
}
|
||||
return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' });
|
||||
}
|
||||
|
||||
/** 组织架构:两级部门树(含成员数与负责人)、新建、改名 */
|
||||
/** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */
|
||||
@Injectable()
|
||||
export class DepartmentsService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async tree(): Promise<DepartmentTreeNode[]> {
|
||||
const [departments, counts] = await Promise.all([
|
||||
async tree(includeDisabled: boolean): Promise<DepartmentTreeNode[]> {
|
||||
const [departments, counts, lineage] = await Promise.all([
|
||||
this.prisma.department.findMany({
|
||||
where: includeDisabled ? {} : { status: DB_ACTIVE },
|
||||
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
include: { leader: { select: { id: true, name: true } } },
|
||||
}),
|
||||
@@ -48,16 +65,30 @@ export class DepartmentsService {
|
||||
where: { status: 'ACTIVE' },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
// 全量父子关系:即使子部门被过滤,也要把其直属在职成员计入一级部门 memberCount
|
||||
this.prisma.department.findMany({ select: { id: true, parentId: true } }),
|
||||
]);
|
||||
const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all]));
|
||||
return this.buildTree(departments, byDept, lineage);
|
||||
}
|
||||
|
||||
private buildTree(
|
||||
departments: DepartmentRow[],
|
||||
byDept: Map<string, number>,
|
||||
lineage: { id: string; parentId: string | null }[],
|
||||
): DepartmentTreeNode[] {
|
||||
const nodes = new Map<string, DepartmentTreeNode>();
|
||||
for (const department of departments) {
|
||||
const direct = byDept.get(department.id) ?? 0;
|
||||
nodes.set(department.id, {
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
parentId: department.parentId,
|
||||
leader: department.leader,
|
||||
memberCount: byDept.get(department.id) ?? 0,
|
||||
status: toApiStatus(department.status),
|
||||
sortOrder: department.sortOrder,
|
||||
memberCount: direct,
|
||||
directMemberCount: direct,
|
||||
createdAt: department.createdAt.toISOString(),
|
||||
children: [],
|
||||
});
|
||||
@@ -68,25 +99,36 @@ export class DepartmentsService {
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
const parent = nodes.get(node.parentId);
|
||||
if (parent !== undefined) {
|
||||
parent.children.push(node);
|
||||
nodes.get(node.parentId)?.children.push(node);
|
||||
}
|
||||
const childDirect = new Map<string, number>();
|
||||
for (const row of lineage) {
|
||||
if (row.parentId !== null) {
|
||||
childDirect.set(
|
||||
row.parentId,
|
||||
(childDirect.get(row.parentId) ?? 0) + (byDept.get(row.id) ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const root of roots) {
|
||||
root.memberCount += root.children.reduce((sum, child) => sum + child.memberCount, 0);
|
||||
root.memberCount += childDirect.get(root.id) ?? 0;
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async create(input: CreateDepartmentPayload): Promise<{ id: string }> {
|
||||
async create(input: CreateDepartmentInput): Promise<{ id: string }> {
|
||||
await this.assertParentUsable(input.parentId);
|
||||
await this.assertLeaderUsable(input.leaderUserId);
|
||||
await this.assertNameFree(input.name, input.parentId, null);
|
||||
try {
|
||||
const created = await this.prisma.department.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
name: input.name,
|
||||
parentId: input.parentId,
|
||||
leaderUserId: input.leaderUserId ?? null,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
status: DB_ACTIVE,
|
||||
code: codeOf(input.name),
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -97,26 +139,85 @@ export class DepartmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
async rename(id: string, name: string): Promise<void> {
|
||||
const existing = await this.prisma.department.findUnique({ where: { id } });
|
||||
if (existing === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
|
||||
async update(id: string, input: UpdateDepartmentInput): Promise<void> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (input.leaderUserId !== undefined) {
|
||||
await this.assertLeaderUsable(input.leaderUserId);
|
||||
}
|
||||
if (input.name !== undefined) {
|
||||
await this.assertNameFree(input.name, existing.parentId, id);
|
||||
}
|
||||
const data: Prisma.DepartmentUpdateInput = {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||
...(input.leaderUserId === undefined
|
||||
? {}
|
||||
: {
|
||||
leader:
|
||||
input.leaderUserId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: input.leaderUserId } },
|
||||
}),
|
||||
};
|
||||
try {
|
||||
await this.prisma.department.update({ where: { id }, data: { name } });
|
||||
await this.prisma.department.update({ where: { id }, data });
|
||||
} catch (error) {
|
||||
throw mapDepartmentError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async disable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (existing.status !== DB_ACTIVE) {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是停用状态' });
|
||||
}
|
||||
await this.assertNoActiveMembers(existing);
|
||||
await this.prisma.department.update({ where: { id }, data: { status: DB_DISABLED } });
|
||||
return { id, status: 'disabled' };
|
||||
}
|
||||
|
||||
async enable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (existing.status === DB_ACTIVE) {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是启用状态' });
|
||||
}
|
||||
await this.assertParentActiveForEnable(existing.parentId);
|
||||
await this.prisma.department.update({ where: { id }, data: { status: DB_ACTIVE } });
|
||||
return { id, status: 'active' };
|
||||
}
|
||||
|
||||
private async findOrThrow(id: string): Promise<{
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
status: string;
|
||||
}> {
|
||||
const department = await this.prisma.department.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, parentId: true, status: true },
|
||||
});
|
||||
if (department === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
|
||||
}
|
||||
return department;
|
||||
}
|
||||
|
||||
private async assertParentUsable(parentId: string | null): Promise<void> {
|
||||
if (parentId === null) {
|
||||
return;
|
||||
}
|
||||
const parent = await this.prisma.department.findUnique({ where: { id: parentId } });
|
||||
const parent = await this.prisma.department.findUnique({
|
||||
where: { id: parentId },
|
||||
select: { parentId: true, status: true },
|
||||
});
|
||||
if (parent === null) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' });
|
||||
}
|
||||
if (parent.status !== DB_ACTIVE) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '上级部门已停用,无法在其下新建部门',
|
||||
});
|
||||
}
|
||||
if (parent.parentId !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
@@ -124,4 +225,93 @@ export class DepartmentsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLeaderUsable(leaderUserId: string | null | undefined): Promise<void> {
|
||||
if (leaderUserId === null || leaderUserId === undefined) {
|
||||
return;
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: leaderUserId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (user?.status !== 'ACTIVE') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '负责人必须是启用状态的用户',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertNameFree(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
excludeId: string | null,
|
||||
): Promise<void> {
|
||||
const duplicated = await this.prisma.department.findFirst({
|
||||
where: {
|
||||
parentId,
|
||||
name,
|
||||
...(excludeId === null ? {} : { id: { not: excludeId } }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicated !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: DUPLICATE_NAME_MESSAGE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertNoActiveMembers(department: {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
}): Promise<void> {
|
||||
const direct = await this.prisma.user.count({
|
||||
where: { departmentId: department.id, status: 'ACTIVE' },
|
||||
});
|
||||
if (direct > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: `部门下仍有 ${String(direct)} 名在职成员,请先调整成员所属部门后再停用`,
|
||||
});
|
||||
}
|
||||
if (department.parentId !== null) {
|
||||
return;
|
||||
}
|
||||
const activeChildren = await this.prisma.department.count({
|
||||
where: { parentId: department.id, status: DB_ACTIVE },
|
||||
});
|
||||
if (activeChildren > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: `存在 ${String(activeChildren)} 个启用中的下级小组,请先停用下级小组`,
|
||||
});
|
||||
}
|
||||
const childMembers = await this.prisma.user.count({
|
||||
where: { status: 'ACTIVE', department: { parentId: department.id } },
|
||||
});
|
||||
if (childMembers > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: `下级小组仍有 ${String(childMembers)} 名在职成员,请先调整成员所属部门`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertParentActiveForEnable(parentId: string | null): Promise<void> {
|
||||
if (parentId === null) {
|
||||
return;
|
||||
}
|
||||
const parent = await this.prisma.department.findUnique({
|
||||
where: { id: parentId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (parent?.status !== DB_ACTIVE) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '上级部门已停用,请先启用上级部门',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,73 +293,253 @@ describe('UsersService', () => {
|
||||
});
|
||||
|
||||
describe('DepartmentsService', () => {
|
||||
it('部门树:两级嵌套且一级成员数含下级', async () => {
|
||||
const departments = [
|
||||
{
|
||||
function serviceOf(prisma: Record<string, unknown>): DepartmentsService {
|
||||
return new DepartmentsService(prisma as unknown as PrismaService);
|
||||
}
|
||||
|
||||
function departmentRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'd1',
|
||||
code: 'content',
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
leader: { id: 'u1', name: '陈默' },
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
code: 'content-g1',
|
||||
name: '素材一组',
|
||||
parentId: 'd1',
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
sortOrder: 0,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
leader: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('部门树:默认只返回启用部门;memberCount 含下级、directMemberCount 仅直属', async () => {
|
||||
const rows = [
|
||||
departmentRow({ id: 'd1', name: '内容生产中心' }),
|
||||
departmentRow({ id: 'g1', name: '素材一组', parentId: 'd1' }),
|
||||
];
|
||||
const service = new DepartmentsService({
|
||||
department: { findMany: vi.fn().mockResolvedValue(departments) },
|
||||
user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) },
|
||||
} as unknown as PrismaService);
|
||||
const tree = await service.tree();
|
||||
const findMany = vi.fn().mockResolvedValue(rows);
|
||||
const service = serviceOf({
|
||||
department: { findMany },
|
||||
user: {
|
||||
groupBy: vi.fn().mockResolvedValue([
|
||||
{ departmentId: 'd1', _count: { _all: 3 } },
|
||||
{ departmentId: 'g1', _count: { _all: 5 } },
|
||||
]),
|
||||
},
|
||||
});
|
||||
const tree = await service.tree(false);
|
||||
expect(findMany.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ where: { status: 'ACTIVE' } }),
|
||||
);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.memberCount).toBe(5);
|
||||
expect(tree[0]?.memberCount).toBe(8);
|
||||
expect(tree[0]?.directMemberCount).toBe(3);
|
||||
expect(tree[0]?.children[0]?.name).toBe('素材一组');
|
||||
expect(tree[0]?.children[0]?.memberCount).toBe(5);
|
||||
expect(tree[0]?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('新建:父级不存在 40001,超过两级 42201', async () => {
|
||||
const service = new DepartmentsService({
|
||||
it('部门树:include_disabled=true 时包含停用部门且状态映射为 disabled', async () => {
|
||||
const service = serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValueOnce(null),
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
departmentRow({ id: 'd1' }),
|
||||
departmentRow({ id: 'd2', status: 'DISABLED' }),
|
||||
]),
|
||||
},
|
||||
} as unknown as PrismaService);
|
||||
user: { groupBy: vi.fn().mockResolvedValue([]) },
|
||||
});
|
||||
const tree = await service.tree(true);
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree[1]?.status).toBe('disabled');
|
||||
expect(tree[0]?.sortOrder).toBe(0);
|
||||
});
|
||||
|
||||
it('新建:父级不存在 40001、父级停用 / 超两级 42201、负责人非法 42201', async () => {
|
||||
await expectRejected(
|
||||
service.create({ name: '新组', parentId: 'missing' }),
|
||||
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).create({
|
||||
name: '新部门',
|
||||
parentId: 'missing',
|
||||
}),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
|
||||
const service2 = new DepartmentsService({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'g1',
|
||||
parentId: 'd1',
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(
|
||||
service2.create({ name: '三级', parentId: 'g1' }),
|
||||
serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue({ parentId: null, status: 'DISABLED' }),
|
||||
},
|
||||
}).create({ name: '新小组', parentId: 'd1' }),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue({ parentId: 'd0', status: 'ACTIVE' }),
|
||||
},
|
||||
}).create({ name: '三级组', parentId: 'g1' }),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: { findFirst: vi.fn() },
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ status: 'DISABLED' }) },
|
||||
}).create({ name: '新部门', parentId: null, leaderUserId: 'u-disabled' }),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('重命名不存在的部门返回 40401', async () => {
|
||||
const service = new DepartmentsService({
|
||||
department: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND);
|
||||
it('新建:同级重名 40903,成功后落库 sortOrder / status / 负责人', async () => {
|
||||
const duplicated = serviceOf({
|
||||
department: { findFirst: vi.fn().mockResolvedValue({ id: 'other' }) },
|
||||
});
|
||||
await expectRejected(
|
||||
duplicated.create({ name: '内容生产中心', parentId: null }),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
|
||||
const create = vi.fn().mockResolvedValue({ id: 'new-id' });
|
||||
const service = serviceOf({
|
||||
department: { findFirst: vi.fn().mockResolvedValue(null), create },
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) },
|
||||
});
|
||||
await expect(
|
||||
service.create({ name: '内容生产中心', parentId: null, leaderUserId: 'u1', sortOrder: 5 }),
|
||||
).resolves.toEqual({ id: 'new-id' });
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ sortOrder: 5, status: 'ACTIVE', leaderUserId: 'u1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('更新:不存在 40401、负责人非法 42201、重名 40903、支持清空负责人', async () => {
|
||||
await expectRejected(
|
||||
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).update('missing', {
|
||||
name: '新名',
|
||||
}),
|
||||
ErrorCode.NOT_FOUND,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
}).update('d1', { leaderUserId: 'u-missing' }),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue(departmentRow()),
|
||||
findFirst: vi.fn().mockResolvedValue({ id: 'other' }),
|
||||
},
|
||||
}).update('d1', { name: '旧部门' }),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
|
||||
const update = vi.fn().mockResolvedValue(departmentRow());
|
||||
const service = serviceOf({
|
||||
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()), update },
|
||||
});
|
||||
await service.update('d1', { leaderUserId: null, sortOrder: 2 });
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 'd1' },
|
||||
data: { sortOrder: 2, leader: { disconnect: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it('停用:已停用 40903;本部门有在职成员 / 启用子部门 / 下级成员分别 42201', async () => {
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
|
||||
},
|
||||
}).disable('d1'),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
|
||||
user: { count: vi.fn().mockResolvedValue(2) },
|
||||
}).disable('d1'),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue(departmentRow()),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
user: { count: vi.fn().mockResolvedValue(0) },
|
||||
}).disable('d1'),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue(departmentRow()),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
user: { count: vi.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(3) },
|
||||
}).disable('d1'),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('停用成功:无成员无启用子部门时落库 DISABLED;不提供删除方法', async () => {
|
||||
const update = vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' }));
|
||||
const service = serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue(departmentRow()),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
update,
|
||||
},
|
||||
user: { count: vi.fn().mockResolvedValue(0) },
|
||||
});
|
||||
await expect(service.disable('d1')).resolves.toEqual({ id: 'd1', status: 'disabled' });
|
||||
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'DISABLED' } });
|
||||
expect('delete' in service).toBe(false);
|
||||
});
|
||||
|
||||
it('启用:已启用 40903、上级停用 42201、成功后落库 ACTIVE', async () => {
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
|
||||
}).enable('d1'),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
|
||||
await expectRejected(
|
||||
serviceOf({
|
||||
department: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(departmentRow({ status: 'DISABLED', parentId: 'd0' }))
|
||||
.mockResolvedValueOnce({ status: 'DISABLED' }),
|
||||
},
|
||||
}).enable('g1'),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
|
||||
const update = vi.fn().mockResolvedValue(departmentRow());
|
||||
const service = serviceOf({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
|
||||
update,
|
||||
},
|
||||
});
|
||||
await expect(service.enable('d1')).resolves.toEqual({ id: 'd1', status: 'active' });
|
||||
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'ACTIVE' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -219,10 +219,19 @@ export class UsersService {
|
||||
}
|
||||
|
||||
private async ensureDepartment(departmentId: string): Promise<void> {
|
||||
const department = await this.prisma.department.findUnique({ where: { id: departmentId } });
|
||||
const department = await this.prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (department === null) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' });
|
||||
}
|
||||
if (department.status !== 'ACTIVE') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '所属部门已停用,请选择启用中的部门',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoles(roleIds: string[]): Promise<void> {
|
||||
|
||||
@@ -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 =>
|
||||
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
|
||||
departments: '/departments/tree',
|
||||
departmentsRoot: '/departments',
|
||||
department: (departmentId: string): string => `/departments/${encodeURIComponent(departmentId)}`,
|
||||
departmentDisable: (departmentId: string): string =>
|
||||
`/departments/${encodeURIComponent(departmentId)}/disable`,
|
||||
departmentEnable: (departmentId: string): string =>
|
||||
`/departments/${encodeURIComponent(departmentId)}/enable`,
|
||||
roles: '/roles',
|
||||
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
||||
permissions: '/permissions',
|
||||
|
||||
@@ -110,7 +110,10 @@ describe('identity 接口封装', () => {
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 1,
|
||||
memberCount: 19,
|
||||
directMemberCount: 4,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
children: [
|
||||
{
|
||||
@@ -118,7 +121,10 @@ describe('identity 接口封装', () => {
|
||||
name: '素材一组',
|
||||
parentId: GROUP_ID,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 1,
|
||||
memberCount: 9,
|
||||
directMemberCount: 9,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
children: [],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type ChangePasswordInput,
|
||||
changePasswordInputSchema,
|
||||
type CreateDepartmentInput,
|
||||
createDepartmentInputSchema,
|
||||
type CreateUserInput,
|
||||
createUserInputSchema,
|
||||
type DepartmentNode,
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
roleSummarySchema,
|
||||
type TransferCustomersInput,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateDepartmentInput,
|
||||
updateDepartmentInputSchema,
|
||||
type UpdateUserInput,
|
||||
updateUserInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
@@ -32,7 +36,7 @@ export interface CustomerSummary {
|
||||
}
|
||||
|
||||
const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) });
|
||||
const createdUserSchema = z.object({ id: z.string().min(1) }).passthrough();
|
||||
const createdIdSchema = z.object({ id: z.string().min(1) }).passthrough();
|
||||
|
||||
/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */
|
||||
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
|
||||
@@ -69,9 +73,13 @@ export async function fetchMe(): Promise<MeResponse> {
|
||||
return meResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */
|
||||
export async function fetchDepartments(): Promise<DepartmentNode[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.departments);
|
||||
/**
|
||||
* 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用。
|
||||
* includeDisabled=true 时包含已停用部门(部门管理页需要),默认只看启用。
|
||||
*/
|
||||
export async function fetchDepartments(includeDisabled = false): Promise<DepartmentNode[]> {
|
||||
const search = includeDisabled ? '?include_disabled=true' : '?include_disabled=false';
|
||||
const payload: unknown = await request(`${ENDPOINTS.departments}${search}`);
|
||||
const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
@@ -93,6 +101,32 @@ export async function fetchDepartments(): Promise<DepartmentNode[]> {
|
||||
return flat;
|
||||
}
|
||||
|
||||
export async function createDepartment(input: CreateDepartmentInput): Promise<string> {
|
||||
const body = createDepartmentInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.departmentsRoot, { method: 'POST', body });
|
||||
const parsed = createdIdSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error('部门创建成功但未返回部门 ID');
|
||||
}
|
||||
return parsed.data.id;
|
||||
}
|
||||
|
||||
export async function updateDepartment(
|
||||
departmentId: string,
|
||||
input: UpdateDepartmentInput,
|
||||
): Promise<void> {
|
||||
const body = updateDepartmentInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.department(departmentId), { method: 'PATCH', body });
|
||||
}
|
||||
|
||||
export async function disableDepartment(departmentId: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.departmentDisable(departmentId), { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function enableDepartment(departmentId: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.departmentEnable(departmentId), { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchRoles(): Promise<RoleSummary[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.roles);
|
||||
return parseList(payload, roleSummarySchema);
|
||||
@@ -102,7 +136,7 @@ export async function fetchRoles(): Promise<RoleSummary[]> {
|
||||
export async function createUser(input: CreateUserInput): Promise<string | null> {
|
||||
const body = createUserInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body });
|
||||
const parsed = createdUserSchema.safeParse(payload);
|
||||
const parsed = createdIdSchema.safeParse(payload);
|
||||
return parsed.success ? parsed.data.id : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const queryKeys = {
|
||||
userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
|
||||
user: (userId: string) => ['users', 'detail', userId] as const,
|
||||
userCustomers: (userId: string) => ['users', 'customers', userId] as const,
|
||||
departments: ['departments'] as const,
|
||||
departments: (includeDisabled: boolean) => ['departments', { includeDisabled }] as const,
|
||||
roles: ['roles'] as const,
|
||||
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
||||
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { type DepartmentNode, type RoleSummary } from '@lingxi/shared';
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
import type {
|
||||
CreateDepartmentInput,
|
||||
DepartmentNode,
|
||||
RoleSummary,
|
||||
UpdateDepartmentInput,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
useMutation,
|
||||
type UseMutationResult,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { fetchDepartments, fetchRoles } from '@/api/identity';
|
||||
import {
|
||||
createDepartment,
|
||||
disableDepartment,
|
||||
enableDepartment,
|
||||
fetchDepartments,
|
||||
fetchRoles,
|
||||
updateDepartment,
|
||||
} from '@/api/identity';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
|
||||
export function useDepartmentsQuery(): UseQueryResult<DepartmentNode[]> {
|
||||
/** 部门树:默认只取启用部门(用户页选择器);部门管理页传 includeDisabled=true */
|
||||
export function useDepartmentsQuery(includeDisabled = false): UseQueryResult<DepartmentNode[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.departments,
|
||||
queryFn: fetchDepartments,
|
||||
queryKey: queryKeys.departments(includeDisabled),
|
||||
queryFn: () => fetchDepartments(includeDisabled),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -19,3 +38,63 @@ export function useRolesQuery(): UseQueryResult<RoleSummary[]> {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 部门写操作:失效部门树与用户缓存(用户列表/详情的部门名称与人数会变) */
|
||||
function useInvalidateDirectory(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
return async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['departments'] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users }),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateDepartmentMutation(): UseMutationResult<
|
||||
string,
|
||||
Error,
|
||||
CreateDepartmentInput
|
||||
> {
|
||||
const invalidate = useInvalidateDirectory();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateDepartmentInput) => createDepartment(input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateDepartmentMutation(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ departmentId: string; input: UpdateDepartmentInput }
|
||||
> {
|
||||
const invalidate = useInvalidateDirectory();
|
||||
return useMutation({
|
||||
mutationFn: ({ departmentId, input }: { departmentId: string; input: UpdateDepartmentInput }) =>
|
||||
updateDepartment(departmentId, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisableDepartmentMutation(): UseMutationResult<void, Error, string> {
|
||||
const invalidate = useInvalidateDirectory();
|
||||
return useMutation({
|
||||
mutationFn: (departmentId: string) => disableDepartment(departmentId),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableDepartmentMutation(): UseMutationResult<void, Error, string> {
|
||||
const invalidate = useInvalidateDirectory();
|
||||
return useMutation({
|
||||
mutationFn: (departmentId: string) => enableDepartment(departmentId),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,9 +11,13 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
|
||||
import { SideNav } from './SideNav';
|
||||
import { TopBar } from './TopBar';
|
||||
|
||||
/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */
|
||||
/** 用户与组织、部门管理为「满高主从布局」,由页面自己控制内部滚动 */
|
||||
function isFlushRoute(pathname: string): boolean {
|
||||
return pathname === ROUTES.system.users || pathname.startsWith(`${ROUTES.system.users}/`);
|
||||
return (
|
||||
pathname === ROUTES.system.users ||
|
||||
pathname.startsWith(`${ROUTES.system.users}/`) ||
|
||||
pathname === ROUTES.system.departments
|
||||
);
|
||||
}
|
||||
|
||||
export function AppLayout(): ReactNode {
|
||||
|
||||
@@ -8,6 +8,7 @@ import '@/styles/pages.css';
|
||||
import '@/styles/users.css';
|
||||
import '@/styles/roles.css';
|
||||
import '@/styles/role-editor.css';
|
||||
import '@/styles/departments.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
|
||||
@@ -44,6 +44,15 @@ describe('导航配置', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('部门管理菜单使用 user:view,仅查看权限也能进入', () => {
|
||||
const departmentsItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find(
|
||||
(item) => item.key === 'departments',
|
||||
);
|
||||
expect(departmentsItem?.path).toBe('/system/departments');
|
||||
expect(departmentsItem?.permission).toBe('user:view');
|
||||
expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理');
|
||||
});
|
||||
|
||||
it('按路径定位当前模块与菜单', () => {
|
||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||
expect(active?.module.key).toBe('material');
|
||||
@@ -55,5 +64,7 @@ describe('导航配置', () => {
|
||||
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
|
||||
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/users');
|
||||
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '用户与组织']);
|
||||
const deptBreadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/departments');
|
||||
expect(deptBreadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '部门管理']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Permission, type PermissionCode } from '@lingxi/shared';
|
||||
import {
|
||||
IconApps,
|
||||
IconAsset,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
@@ -254,6 +255,14 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
permission: Permission.USER_VIEW,
|
||||
description: '账号、组织与登录状态',
|
||||
},
|
||||
{
|
||||
key: 'departments',
|
||||
label: '部门管理',
|
||||
path: '/system/departments',
|
||||
icon: IconApps,
|
||||
permission: Permission.USER_VIEW,
|
||||
description: '两级组织架构、负责人、排序与停用状态',
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
label: '角色管理',
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
/* eslint-disable max-lines-per-function -- 详情面板按视图/编辑两种形态分区,集中便于与表单状态对齐 */
|
||||
import { Button, Input, InputNumber, Select } from '@douyinfe/semi-ui-19';
|
||||
import type { DepartmentNode, UserSummary } from '@lingxi/shared';
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { PageEmpty } from '@lingxi/ui';
|
||||
import { IconAdd, IconEdit, IconLock, IconPlay } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
|
||||
import type { DepartmentFormErrors, DepartmentFormValues } from './department-form';
|
||||
import {
|
||||
childMemberCount,
|
||||
departmentDisableBlocker,
|
||||
departmentEnableBlocker,
|
||||
type DepartmentTreeModel,
|
||||
} from './department-tree';
|
||||
import { DepartmentStatusTag } from './DepartmentStatusTag';
|
||||
|
||||
export interface DepartmentDetailPanelProps {
|
||||
node: DepartmentNode | null;
|
||||
model: DepartmentTreeModel;
|
||||
canManage: boolean;
|
||||
editing: boolean;
|
||||
form: DepartmentFormValues;
|
||||
errors: DepartmentFormErrors;
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
leaderCandidates: readonly UserSummary[];
|
||||
onStartEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onChangeField: (field: keyof DepartmentFormValues, value: string) => void;
|
||||
onSave: () => void;
|
||||
onDisable: () => void;
|
||||
onEnable: () => void;
|
||||
onCreateChild: () => void;
|
||||
onSelect: (departmentId: string) => void;
|
||||
}
|
||||
|
||||
function DetailHead({
|
||||
node,
|
||||
parent,
|
||||
canManage,
|
||||
disableBlocker,
|
||||
enableBlocker,
|
||||
onStartEdit,
|
||||
onDisable,
|
||||
onEnable,
|
||||
}: {
|
||||
node: DepartmentNode;
|
||||
parent: DepartmentNode | null;
|
||||
canManage: boolean;
|
||||
disableBlocker: string | null;
|
||||
enableBlocker: string | null;
|
||||
onStartEdit: () => void;
|
||||
onDisable: () => void;
|
||||
onEnable: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<header className="lx-dept-detail__head">
|
||||
<span className="lx-dept-detail__avatar" aria-hidden="true">
|
||||
{node.name.slice(0, 1)}
|
||||
</span>
|
||||
<div className="lx-dept-detail__ident">
|
||||
<div className="lx-dept-detail__name">
|
||||
<h2>{node.name}</h2>
|
||||
<DepartmentStatusTag status={node.status} />
|
||||
<span className="lx-dept-tag">{node.parentId === null ? '一级部门' : '小组'}</span>
|
||||
</div>
|
||||
<p className="lx-dept-detail__meta">
|
||||
上级 {parent?.name ?? '—'} · 负责人 {node.leader?.name ?? '未指定'} · 排序{' '}
|
||||
{node.sortOrder}
|
||||
</p>
|
||||
</div>
|
||||
<Can permission={Permission.USER_MANAGE}>
|
||||
<div className="lx-dept-detail__actions">
|
||||
<Button icon={<IconEdit size={14} />} onClick={onStartEdit}>
|
||||
编辑
|
||||
</Button>
|
||||
{node.status === 'active' ? (
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconLock size={14} />}
|
||||
disabled={disableBlocker !== null}
|
||||
title={disableBlocker ?? undefined}
|
||||
onClick={onDisable}
|
||||
>
|
||||
停用
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon={<IconPlay size={14} />}
|
||||
disabled={enableBlocker !== null}
|
||||
title={enableBlocker ?? undefined}
|
||||
onClick={onEnable}
|
||||
>
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
{canManage ? null : <span className="lx-dept-detail__readonly">只读权限</span>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoGrid({
|
||||
node,
|
||||
parent,
|
||||
}: {
|
||||
node: DepartmentNode;
|
||||
parent: DepartmentNode | null;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-dept-detail__section">
|
||||
<h3 className="lx-dept-detail__section-title">基本信息</h3>
|
||||
<div className="lx-dept-kv-grid">
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">部门名称</span>
|
||||
<span className="lx-kvi__value">{node.name}</span>
|
||||
</div>
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">上级部门</span>
|
||||
<span className="lx-kvi__value">{parent?.name ?? '—(一级部门)'}</span>
|
||||
</div>
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">负责人</span>
|
||||
<span className="lx-kvi__value">{node.leader?.name ?? '未指定'}</span>
|
||||
</div>
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">排序</span>
|
||||
<span className="lx-kvi__value">{node.sortOrder}</span>
|
||||
</div>
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">状态</span>
|
||||
<span className="lx-kvi__value">
|
||||
<DepartmentStatusTag status={node.status} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">创建时间</span>
|
||||
<span className="lx-kvi__value">{node.createdAt.slice(0, 16).replace('T', ' ')}</span>
|
||||
</div>
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">直属成员</span>
|
||||
<span className="lx-kvi__value">{node.directMemberCount} 人</span>
|
||||
</div>
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">含下级成员</span>
|
||||
<span className="lx-kvi__value">{node.memberCount} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildrenSection({
|
||||
node,
|
||||
model,
|
||||
onCreateChild,
|
||||
onSelect,
|
||||
}: {
|
||||
node: DepartmentNode;
|
||||
model: DepartmentTreeModel;
|
||||
onCreateChild: () => void;
|
||||
onSelect: (departmentId: string) => void;
|
||||
}): ReactNode {
|
||||
if (node.parentId !== null) {
|
||||
return null;
|
||||
}
|
||||
const children = model.nodes.filter((child) => child.parentId === node.id);
|
||||
return (
|
||||
<section className="lx-dept-detail__section">
|
||||
<div className="lx-dept-detail__section-head">
|
||||
<h3 className="lx-dept-detail__section-title">下级小组</h3>
|
||||
<span className="lx-dept-detail__muted">{children.length} 个</span>
|
||||
<Can permission={Permission.USER_MANAGE}>
|
||||
<Button size="small" icon={<IconAdd size={14} />} onClick={onCreateChild}>
|
||||
新建小组
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
{children.length === 0 ? (
|
||||
<p className="lx-dept-detail__muted">暂无下级小组。</p>
|
||||
) : (
|
||||
<ul className="lx-dept-children">
|
||||
{children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-dept-child-row"
|
||||
onClick={() => {
|
||||
onSelect(child.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-dept-child-row__name">
|
||||
{child.name}
|
||||
<DepartmentStatusTag status={child.status} />
|
||||
</span>
|
||||
<span className="lx-dept-child-row__meta">
|
||||
负责人 {child.leader?.name ?? '未指定'} · {child.memberCount} 人
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({
|
||||
node,
|
||||
form,
|
||||
errors,
|
||||
saving,
|
||||
saveError,
|
||||
leaderCandidates,
|
||||
onChange,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
node: DepartmentNode;
|
||||
form: DepartmentFormValues;
|
||||
errors: DepartmentFormErrors;
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
leaderCandidates: readonly UserSummary[];
|
||||
onChange: (field: keyof DepartmentFormValues, value: string) => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-dept-detail__section lx-dept-edit">
|
||||
<h3 className="lx-dept-detail__section-title">编辑部门</h3>
|
||||
<p className="lx-dept-detail__muted">
|
||||
上级关系创建后不可移动;负责人仅可从启用中的用户选择。
|
||||
</p>
|
||||
<div className="lx-dept-edit__grid">
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">部门名称</span>
|
||||
<Input
|
||||
value={form.name}
|
||||
maxLength={20}
|
||||
placeholder="请输入部门名称"
|
||||
onChange={(value) => {
|
||||
onChange('name', value);
|
||||
}}
|
||||
/>
|
||||
{errors.name === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.name}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">负责人</span>
|
||||
<Select
|
||||
value={form.leaderUserId === '' ? undefined : form.leaderUserId}
|
||||
placeholder="未指定"
|
||||
showClear
|
||||
optionList={leaderCandidates.map((user) => ({
|
||||
label: `${user.name} · ${user.account}`,
|
||||
value: user.id,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
onChange('leaderUserId', typeof value === 'string' ? value : '');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">排序</span>
|
||||
<InputNumber
|
||||
value={form.sortOrder === '' ? undefined : Number(form.sortOrder)}
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="0"
|
||||
onChange={(value) => {
|
||||
onChange('sortOrder', String(value));
|
||||
}}
|
||||
/>
|
||||
{errors.sortOrder === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.sortOrder}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
{saveError === null ? null : <p className="lx-field__error">{saveError}</p>}
|
||||
<div className="lx-dept-edit__actions">
|
||||
<Button onClick={onCancel} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button theme="solid" type="primary" loading={saving} onClick={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<p className="lx-dept-detail__muted">当前编辑:{node.name}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 右侧部门详情 / 内联编辑 */
|
||||
export function DepartmentDetailPanel(props: DepartmentDetailPanelProps): ReactNode {
|
||||
const { node, model } = props;
|
||||
if (node === null) {
|
||||
return (
|
||||
<section className="lx-dept-detail" aria-label="部门详情">
|
||||
<div className="lx-dept-detail__empty">
|
||||
<PageEmpty title="选择左侧部门查看详情" description="支持新建一级部门与下级小组。" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const parent = node.parentId === null ? null : (model.byId.get(node.parentId) ?? null);
|
||||
const children = model.nodes.filter((child) => child.parentId === node.id);
|
||||
const disableBlocker = departmentDisableBlocker(node, children);
|
||||
const enableBlocker = departmentEnableBlocker(node, parent);
|
||||
|
||||
return (
|
||||
<section className="lx-dept-detail" aria-label="部门详情">
|
||||
<DetailHead
|
||||
node={node}
|
||||
parent={parent}
|
||||
canManage={props.canManage}
|
||||
disableBlocker={disableBlocker}
|
||||
enableBlocker={enableBlocker}
|
||||
onStartEdit={props.onStartEdit}
|
||||
onDisable={props.onDisable}
|
||||
onEnable={props.onEnable}
|
||||
/>
|
||||
<div className="lx-dept-detail__body">
|
||||
{disableBlocker !== null && node.status === 'active' ? (
|
||||
<p className="lx-dept-notice" role="status">
|
||||
当前不可停用:{disableBlocker}
|
||||
</p>
|
||||
) : null}
|
||||
{enableBlocker !== null && node.status === 'disabled' ? (
|
||||
<p className="lx-dept-notice" role="status">
|
||||
当前不可启用:{enableBlocker}
|
||||
</p>
|
||||
) : null}
|
||||
{props.editing ? (
|
||||
<EditForm
|
||||
node={node}
|
||||
form={props.form}
|
||||
errors={props.errors}
|
||||
saving={props.saving}
|
||||
saveError={props.saveError}
|
||||
leaderCandidates={props.leaderCandidates}
|
||||
onChange={props.onChangeField}
|
||||
onCancel={props.onCancelEdit}
|
||||
onSave={props.onSave}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<InfoGrid node={node} parent={parent} />
|
||||
<ChildrenSection
|
||||
node={node}
|
||||
model={model}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onSelect={props.onSelect}
|
||||
/>
|
||||
{childMemberCount(node) > 0 ? (
|
||||
<p className="lx-dept-detail__muted">
|
||||
含下级成员 {node.memberCount} 人,其中直属 {node.directMemberCount} 人。
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DepartmentStatus } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface DepartmentStatusTagProps {
|
||||
status: DepartmentStatus;
|
||||
}
|
||||
|
||||
/** 部门状态标签:启用 / 停用(停用仅影响新分配与选择器,历史数据保留) */
|
||||
export function DepartmentStatusTag({ status }: DepartmentStatusTagProps): ReactNode {
|
||||
const active = status === 'active';
|
||||
return (
|
||||
<span className={active ? 'lx-status-tag is-active' : 'lx-status-tag is-disabled'}>
|
||||
<span className="lx-status-tag__dot" aria-hidden="true" />
|
||||
{active ? '启用' : '停用'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable max-lines-per-function -- 树面板包含根/子节点两种行与三态展示,集中便于对齐交互 */
|
||||
import { Button, Skeleton, Switch } from '@douyinfe/semi-ui-19';
|
||||
import type { DepartmentNode } from '@lingxi/shared';
|
||||
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||
import { IconAdd, IconChevronDown, IconChevronRight } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { childMemberCount, type DepartmentTreeModel } from './department-tree';
|
||||
import { DepartmentStatusTag } from './DepartmentStatusTag';
|
||||
|
||||
export interface DepartmentTreePanelProps {
|
||||
model: DepartmentTreeModel;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
showDisabled: boolean;
|
||||
canManage: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (departmentId: string) => void;
|
||||
onRetry: () => void;
|
||||
onToggleShowDisabled: (checked: boolean) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function DepartmentRow({
|
||||
node,
|
||||
selected,
|
||||
depth,
|
||||
expanded,
|
||||
hasChildren,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: {
|
||||
node: DepartmentNode;
|
||||
selected: boolean;
|
||||
depth: 0 | 1;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
onSelect: () => void;
|
||||
onToggle: () => void;
|
||||
}): ReactNode {
|
||||
const disabled = node.status === 'disabled';
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'lx-dept-row',
|
||||
depth === 1 ? 'is-child' : '',
|
||||
selected ? 'is-selected' : '',
|
||||
disabled ? 'is-disabled' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{depth === 0 && hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lx-dept-row__caret"
|
||||
aria-label={expanded ? '收起下级小组' : '展开下级小组'}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="lx-dept-row__caret lx-dept-row__caret--placeholder" aria-hidden="true" />
|
||||
)}
|
||||
<button type="button" className="lx-dept-row__main" onClick={onSelect}>
|
||||
<span className="lx-dept-row__name">
|
||||
<b>{node.name}</b>
|
||||
<DepartmentStatusTag status={node.status} />
|
||||
</span>
|
||||
<span className="lx-dept-row__meta">负责人 {node.leader?.name ?? '未指定'}</span>
|
||||
</button>
|
||||
<span className="lx-dept-row__count" title={`直属 ${String(node.directMemberCount)} 人`}>
|
||||
{depth === 0 && childMemberCount(node) > 0
|
||||
? `${String(node.memberCount)} 人`
|
||||
: `${String(node.directMemberCount)} 人`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeBody({
|
||||
props,
|
||||
}: {
|
||||
props: Pick<
|
||||
DepartmentTreePanelProps,
|
||||
'model' | 'selectedId' | 'onSelect' | 'canManage' | 'onCreate' | 'onRetry' | 'error' | 'loading'
|
||||
>;
|
||||
}): ReactNode {
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
||||
if (props.loading) {
|
||||
return (
|
||||
<div className="lx-dept-tree__state">
|
||||
<Skeleton placeholder={<Skeleton.Paragraph rows={7} />} loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (props.error !== null) {
|
||||
return (
|
||||
<ErrorState
|
||||
className="lx-dept-tree__state"
|
||||
title="部门加载失败"
|
||||
message={props.error.message}
|
||||
onRetry={props.onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (props.model.roots.length === 0) {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="暂无部门"
|
||||
description="创建一级部门后再维护下属小组。"
|
||||
action={
|
||||
props.canManage ? (
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
onClick={props.onCreate}
|
||||
>
|
||||
新建部门
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="lx-dept-tree__list">
|
||||
{props.model.roots.map((root) => {
|
||||
const isExpanded = !collapsed.has(root.node.id);
|
||||
return (
|
||||
<li key={root.node.id}>
|
||||
<DepartmentRow
|
||||
node={root.node}
|
||||
depth={0}
|
||||
selected={root.node.id === props.selectedId}
|
||||
expanded={isExpanded}
|
||||
hasChildren={root.children.length > 0}
|
||||
onSelect={() => {
|
||||
props.onSelect(root.node.id);
|
||||
}}
|
||||
onToggle={() => {
|
||||
setCollapsed((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(root.node.id)) {
|
||||
next.delete(root.node.id);
|
||||
} else {
|
||||
next.add(root.node.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{isExpanded && root.children.length > 0 ? (
|
||||
<ul className="lx-dept-tree__children">
|
||||
{root.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<DepartmentRow
|
||||
node={child}
|
||||
depth={1}
|
||||
selected={child.id === props.selectedId}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
onSelect={() => {
|
||||
props.onSelect(child.id);
|
||||
}}
|
||||
onToggle={() => undefined}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 左侧组织树:两级结构、人数、负责人、状态与「显示已停用」开关 */
|
||||
export function DepartmentTreePanel(props: DepartmentTreePanelProps): ReactNode {
|
||||
return (
|
||||
<section className="lx-dept-tree" aria-label="组织架构">
|
||||
<header className="lx-dept-tree__head">
|
||||
<span className="lx-dept-tree__title">组织架构</span>
|
||||
<span className="lx-dept-tree__count">{props.model.nodes.length} 个</span>
|
||||
<label className="lx-dept-tree__toggle">
|
||||
<Switch size="small" checked={props.showDisabled} onChange={props.onToggleShowDisabled} />
|
||||
显示已停用
|
||||
</label>
|
||||
</header>
|
||||
<div className="lx-dept-tree__body">
|
||||
<TreeBody props={props} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 部门管理页负责树选中、内联编辑与三类弹窗编排,集中便于追踪数据流 */
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import { type CreateDepartmentInput, type DepartmentNode, Permission } from '@lingxi/shared';
|
||||
import { notifySuccess } from '@lingxi/ui';
|
||||
import { IconAdd } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useCreateDepartmentMutation,
|
||||
useDepartmentsQuery,
|
||||
useDisableDepartmentMutation,
|
||||
useEnableDepartmentMutation,
|
||||
useUpdateDepartmentMutation,
|
||||
} from '@/hooks/queries/use-directory';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
|
||||
import { CreateDepartmentDialog, DepartmentStatusDialog } from './department-dialogs';
|
||||
import {
|
||||
type DepartmentFormErrors,
|
||||
type DepartmentFormValues,
|
||||
departmentFormValues,
|
||||
EMPTY_DEPARTMENT_FORM,
|
||||
toUpdateDepartmentInput,
|
||||
} from './department-form';
|
||||
import {
|
||||
buildDepartmentTree,
|
||||
departmentDisableBlocker,
|
||||
departmentEnableBlocker,
|
||||
filterTreeByStatus,
|
||||
} from './department-tree';
|
||||
import { DepartmentDetailPanel } from './DepartmentDetailPanel';
|
||||
import { DepartmentTreePanel } from './DepartmentTreePanel';
|
||||
|
||||
type CreateParent = DepartmentNode | null | undefined;
|
||||
|
||||
interface StatusDialogState {
|
||||
mode: 'disable' | 'enable';
|
||||
node: DepartmentNode;
|
||||
}
|
||||
|
||||
function DepartmentsHeader({ onCreate }: { onCreate: () => void }): ReactNode {
|
||||
return (
|
||||
<header className="lx-depts__header">
|
||||
<div>
|
||||
<h1>部门管理</h1>
|
||||
<p>维护两级组织架构、负责人、排序与停用状态;部门只停用不删除。</p>
|
||||
</div>
|
||||
<Can permission={Permission.USER_MANAGE}>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
data-testid="dept-create"
|
||||
onClick={onCreate}
|
||||
>
|
||||
新建部门
|
||||
</Button>
|
||||
</Can>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** 部门管理页:左侧两级组织树 + 右侧详情/内联编辑(/system/departments) */
|
||||
export function DepartmentsPage(): ReactNode {
|
||||
const canManage = usePermission(Permission.USER_MANAGE);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showDisabled, setShowDisabled] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<DepartmentFormValues>(EMPTY_DEPARTMENT_FORM);
|
||||
const [formErrors, setFormErrors] = useState<DepartmentFormErrors>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [createParent, setCreateParent] = useState<CreateParent>(undefined);
|
||||
const [statusDialog, setStatusDialog] = useState<StatusDialogState | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const departmentsQuery = useDepartmentsQuery(true);
|
||||
const candidates = useUserCandidatesQuery(canManage);
|
||||
const createMutation = useCreateDepartmentMutation();
|
||||
const updateMutation = useUpdateDepartmentMutation();
|
||||
const disableMutation = useDisableDepartmentMutation();
|
||||
const enableMutation = useEnableDepartmentMutation();
|
||||
|
||||
const model = useMemo(
|
||||
() => buildDepartmentTree(departmentsQuery.data ?? []),
|
||||
[departmentsQuery.data],
|
||||
);
|
||||
const visibleModel = useMemo(
|
||||
() => filterTreeByStatus(model, showDisabled),
|
||||
[model, showDisabled],
|
||||
);
|
||||
const requestedId = searchParams.get('id');
|
||||
const selectedId =
|
||||
requestedId !== null && visibleModel.byId.has(requestedId)
|
||||
? requestedId
|
||||
: (visibleModel.roots[0]?.node.id ?? visibleModel.nodes[0]?.id ?? null);
|
||||
const selectedNode = selectedId === null ? null : (visibleModel.byId.get(selectedId) ?? null);
|
||||
|
||||
const selectDepartment = (departmentId: string): void => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set('id', departmentId);
|
||||
setSearchParams(next, { replace: true });
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const openCreate = (parent: DepartmentNode | null): void => {
|
||||
setActionError(null);
|
||||
setCreateParent(parent);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const submitCreate = (input: CreateDepartmentInput): void => {
|
||||
createMutation.mutate(input, {
|
||||
onSuccess: (id) => {
|
||||
setCreateOpen(false);
|
||||
setCreateParent(undefined);
|
||||
notifySuccess('部门已创建');
|
||||
selectDepartment(id);
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const submitStatus = (): void => {
|
||||
if (statusDialog === null) {
|
||||
return;
|
||||
}
|
||||
const { mode, node } = statusDialog;
|
||||
const onSuccess = (): void => {
|
||||
setStatusDialog(null);
|
||||
notifySuccess(mode === 'disable' ? '部门已停用' : '部门已启用');
|
||||
if (mode === 'disable' && !showDisabled) {
|
||||
setShowDisabled(true);
|
||||
}
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
setActionError(error.message);
|
||||
};
|
||||
if (mode === 'disable') {
|
||||
disableMutation.mutate(node.id, { onSuccess, onError });
|
||||
return;
|
||||
}
|
||||
enableMutation.mutate(node.id, { onSuccess, onError });
|
||||
};
|
||||
|
||||
const startEdit = (): void => {
|
||||
if (selectedNode === null) {
|
||||
return;
|
||||
}
|
||||
setForm(departmentFormValues(selectedNode));
|
||||
setFormErrors({});
|
||||
setSaveError(null);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const saveEdit = (): void => {
|
||||
if (selectedNode === null) {
|
||||
return;
|
||||
}
|
||||
const result = toUpdateDepartmentInput(form);
|
||||
if (result.input === null) {
|
||||
setFormErrors(result.errors);
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate(
|
||||
{ departmentId: selectedNode.id, input: result.input },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
notifySuccess('部门已更新');
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveError(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openStatus = (mode: 'disable' | 'enable', node: DepartmentNode): void => {
|
||||
setActionError(null);
|
||||
setStatusDialog({ mode, node });
|
||||
};
|
||||
|
||||
const createDialogParent = createOpen ? (createParent ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className="lx-depts">
|
||||
<DepartmentsHeader
|
||||
onCreate={() => {
|
||||
openCreate(null);
|
||||
}}
|
||||
/>
|
||||
<div className="lx-depts__split">
|
||||
<DepartmentTreePanel
|
||||
model={visibleModel}
|
||||
loading={departmentsQuery.isPending}
|
||||
error={departmentsQuery.error}
|
||||
showDisabled={showDisabled}
|
||||
canManage={canManage}
|
||||
selectedId={selectedId}
|
||||
onSelect={selectDepartment}
|
||||
onRetry={() => {
|
||||
void departmentsQuery.refetch();
|
||||
}}
|
||||
onToggleShowDisabled={setShowDisabled}
|
||||
onCreate={() => {
|
||||
openCreate(null);
|
||||
}}
|
||||
/>
|
||||
{departmentsQuery.isPending ? (
|
||||
<div className="lx-dept-detail lx-dept-detail--loading">
|
||||
<div className="lx-dept-detail__body">
|
||||
<p className="lx-dept-detail__muted">部门加载中…</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DepartmentDetailPanel
|
||||
node={selectedNode}
|
||||
model={visibleModel}
|
||||
canManage={canManage}
|
||||
editing={editing}
|
||||
form={form}
|
||||
errors={formErrors}
|
||||
saving={updateMutation.isPending}
|
||||
saveError={saveError}
|
||||
leaderCandidates={candidates.data?.items ?? []}
|
||||
onStartEdit={startEdit}
|
||||
onCancelEdit={() => {
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
}}
|
||||
onChangeField={(field, value) => {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
setFormErrors((current) => ({ ...current, [field]: undefined }));
|
||||
}}
|
||||
onSave={saveEdit}
|
||||
onDisable={() => {
|
||||
if (selectedNode !== null) {
|
||||
openStatus('disable', selectedNode);
|
||||
}
|
||||
}}
|
||||
onEnable={() => {
|
||||
if (selectedNode !== null) {
|
||||
openStatus('enable', selectedNode);
|
||||
}
|
||||
}}
|
||||
onCreateChild={() => {
|
||||
if (selectedNode !== null) {
|
||||
openCreate(selectedNode);
|
||||
}
|
||||
}}
|
||||
onSelect={selectDepartment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{createOpen ? (
|
||||
<CreateDepartmentDialog
|
||||
parent={createDialogParent}
|
||||
leaderCandidates={candidates.data?.items ?? []}
|
||||
loading={createMutation.isPending}
|
||||
error={actionError}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateParent(undefined);
|
||||
}}
|
||||
onConfirm={submitCreate}
|
||||
/>
|
||||
) : null}
|
||||
{statusDialog === null ? null : (
|
||||
<DepartmentStatusDialog
|
||||
mode={statusDialog.mode}
|
||||
node={statusDialog.node}
|
||||
blocker={
|
||||
statusDialog.mode === 'disable'
|
||||
? departmentDisableBlocker(
|
||||
statusDialog.node,
|
||||
visibleModel.nodes.filter((node) => node.parentId === statusDialog.node.id),
|
||||
)
|
||||
: departmentEnableBlocker(
|
||||
statusDialog.node,
|
||||
statusDialog.node.parentId === null
|
||||
? null
|
||||
: (visibleModel.byId.get(statusDialog.node.parentId) ?? null),
|
||||
)
|
||||
}
|
||||
loading={disableMutation.isPending || enableMutation.isPending}
|
||||
error={actionError}
|
||||
onCancel={() => {
|
||||
setStatusDialog(null);
|
||||
}}
|
||||
onConfirm={submitStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* eslint-disable max-lines-per-function -- 新建与停用/启用两类弹窗按字段与后果顺序展开,集中便于对齐交互 */
|
||||
import { Button, Checkbox, Input, InputNumber, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import type { CreateDepartmentInput, DepartmentNode, UserSummary } from '@lingxi/shared';
|
||||
import { IconAlert } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
type DepartmentFormErrors,
|
||||
type DepartmentFormValues,
|
||||
EMPTY_DEPARTMENT_FORM,
|
||||
toCreateDepartmentInput,
|
||||
} from './department-form';
|
||||
import { DepartmentStatusTag } from './DepartmentStatusTag';
|
||||
|
||||
export interface CreateDepartmentDialogProps {
|
||||
parent: DepartmentNode | null;
|
||||
leaderCandidates: readonly UserSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (input: CreateDepartmentInput) => void;
|
||||
}
|
||||
|
||||
/** 新建部门 / 小组:上级只读(由页面上下文决定),负责人仅取启用用户 */
|
||||
export function CreateDepartmentDialog({
|
||||
parent,
|
||||
leaderCandidates,
|
||||
loading,
|
||||
error,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: CreateDepartmentDialogProps): ReactNode {
|
||||
const [form, setForm] = useState<DepartmentFormValues>(EMPTY_DEPARTMENT_FORM);
|
||||
const [errors, setErrors] = useState<DepartmentFormErrors>({});
|
||||
|
||||
const setField = (field: keyof DepartmentFormValues, value: string): void => {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
setErrors((current) => ({ ...current, [field]: undefined }));
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
const result = toCreateDepartmentInput(form, parent?.id ?? null);
|
||||
if (result.input === null) {
|
||||
setErrors(result.errors);
|
||||
return;
|
||||
}
|
||||
onConfirm(result.input);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title={parent === null ? '新建一级部门' : '新建下级小组'}
|
||||
width={480}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!loading}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button theme="solid" type="primary" loading={loading} onClick={submit}>
|
||||
创建
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-dept-dialog">
|
||||
<p className="lx-dept-dialog__context">
|
||||
上级:<b>{parent?.name ?? '无(一级部门)'}</b>
|
||||
</p>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">
|
||||
部门名称
|
||||
<span className="lx-field__required">*</span>
|
||||
</span>
|
||||
<Input
|
||||
value={form.name}
|
||||
maxLength={20}
|
||||
placeholder="请输入部门或小组名称"
|
||||
onChange={(value) => {
|
||||
setField('name', value);
|
||||
}}
|
||||
/>
|
||||
{errors.name === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.name}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">负责人</span>
|
||||
<Select
|
||||
value={form.leaderUserId === '' ? undefined : form.leaderUserId}
|
||||
placeholder="未指定(可稍后设置)"
|
||||
showClear
|
||||
optionList={leaderCandidates.map((user) => ({
|
||||
label: `${user.name} · ${user.account}`,
|
||||
value: user.id,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setField('leaderUserId', typeof value === 'string' ? value : '');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">排序</span>
|
||||
<InputNumber
|
||||
value={form.sortOrder === '' ? undefined : Number(form.sortOrder)}
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="0(数字越小越靠前)"
|
||||
onChange={(value) => {
|
||||
setField('sortOrder', String(value));
|
||||
}}
|
||||
/>
|
||||
{errors.sortOrder === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.sortOrder}</span>
|
||||
)}
|
||||
</label>
|
||||
{error === null ? null : <p className="lx-field__error">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DepartmentStatusDialogProps {
|
||||
mode: 'disable' | 'enable';
|
||||
node: DepartmentNode;
|
||||
blocker: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function statusConsequences(mode: 'disable' | 'enable', node: DepartmentNode): string[] {
|
||||
if (mode === 'disable') {
|
||||
return [
|
||||
'停用后该部门不再出现在新建 / 编辑用户的部门选择中',
|
||||
'历史数据与成员归属保留,可随时重新启用',
|
||||
`当前含下级成员 ${String(node.memberCount)} 人、直属 ${String(node.directMemberCount)} 人`,
|
||||
];
|
||||
}
|
||||
return ['启用后恢复出现在部门选择器中', '上级部门必须处于启用状态', '本次变更将写入审计日志'];
|
||||
}
|
||||
|
||||
/** 停用 / 启用二次确认:对象卡 → 规则提示 → 勾选后才能提交;后端拒绝时展示服务端文案 */
|
||||
export function DepartmentStatusDialog({
|
||||
mode,
|
||||
node,
|
||||
blocker,
|
||||
loading,
|
||||
error,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DepartmentStatusDialogProps): ReactNode {
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const disableMode = mode === 'disable';
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title={disableMode ? '停用部门' : '启用部门'}
|
||||
width={520}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!loading}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type={disableMode ? 'danger' : 'primary'}
|
||||
loading={loading}
|
||||
disabled={!acknowledged || blocker !== null}
|
||||
onClick={onConfirm}
|
||||
data-testid="dept-status-confirm"
|
||||
>
|
||||
{disableMode ? '停用部门' : '启用部门'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-confirm-dialog">
|
||||
<div className="lx-confirm-dialog__object">
|
||||
<span className="lx-confirm-dialog__object-icon" aria-hidden="true">
|
||||
{node.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="lx-confirm-dialog__object-copy">
|
||||
<span className="lx-confirm-dialog__object-title">{node.name}</span>
|
||||
<span className="lx-confirm-dialog__object-meta">
|
||||
{node.parentId === null ? '一级部门' : '小组'} · 含下级 {node.memberCount} 人
|
||||
</span>
|
||||
</span>
|
||||
<DepartmentStatusTag status={node.status} />
|
||||
</div>
|
||||
<div
|
||||
className={disableMode ? 'lx-confirm-dialog__warn' : 'lx-confirm-dialog__warn is-info'}
|
||||
>
|
||||
<IconAlert size={16} />
|
||||
<ul>
|
||||
{statusConsequences(mode, node).map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{blocker === null ? null : (
|
||||
<div className="lx-confirm-dialog__warn">
|
||||
<IconAlert size={16} />
|
||||
<div>
|
||||
当前不可{mode === 'disable' ? '停用' : '启用'}:{blocker}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error === null ? null : <p className="lx-field__error">{error}</p>}
|
||||
<Checkbox
|
||||
className="lx-confirm-dialog__ack"
|
||||
checked={acknowledged}
|
||||
onChange={(event) => {
|
||||
setAcknowledged(event.target.checked === true);
|
||||
}}
|
||||
>
|
||||
我已了解上述影响,并确认{mode === 'disable' ? '停用' : '启用'}该部门
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
departmentFormValues,
|
||||
EMPTY_DEPARTMENT_FORM,
|
||||
toCreateDepartmentInput,
|
||||
toUpdateDepartmentInput,
|
||||
} from './department-form';
|
||||
|
||||
const PARENT_ID = '01HZZZZZZZZZZZZZZZZZZZZZ01';
|
||||
const LEADER_ID = '01HZZZZZZZZZZZZZZZZZZZZZ02';
|
||||
|
||||
describe('部门表单校验', () => {
|
||||
it('新建:名称必填、父级与负责人可选', () => {
|
||||
const empty = toCreateDepartmentInput(EMPTY_DEPARTMENT_FORM, null);
|
||||
expect(empty.input).toBeNull();
|
||||
expect(empty.errors.name).toBeTruthy();
|
||||
|
||||
const valid = toCreateDepartmentInput(
|
||||
{ name: ' 设计组 ', leaderUserId: LEADER_ID, sortOrder: '3' },
|
||||
PARENT_ID,
|
||||
);
|
||||
expect(valid.errors).toEqual({});
|
||||
expect(valid.input).toEqual({
|
||||
name: '设计组',
|
||||
parentId: PARENT_ID,
|
||||
leaderUserId: LEADER_ID,
|
||||
sortOrder: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('新建:名称超长与非法排序给出字段错误', () => {
|
||||
const longName = toCreateDepartmentInput(
|
||||
{ name: '一二三四五六七八九十一二三四五六七八九十一', leaderUserId: '', sortOrder: '' },
|
||||
null,
|
||||
);
|
||||
expect(longName.input).toBeNull();
|
||||
expect(longName.errors.name).toContain('20');
|
||||
|
||||
const badSort = toCreateDepartmentInput(
|
||||
{ name: '设计组', leaderUserId: '', sortOrder: '-1' },
|
||||
null,
|
||||
);
|
||||
expect(badSort.input).toBeNull();
|
||||
expect(badSort.errors.sortOrder).toBeTruthy();
|
||||
});
|
||||
|
||||
it('编辑:负责人清空提交 null,上级不可改', () => {
|
||||
const result = toUpdateDepartmentInput({ name: '素材一组', leaderUserId: '', sortOrder: '' });
|
||||
expect(result.input).toEqual({ name: '素材一组', leaderUserId: null });
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('编辑:未填名称时仍以空字符串报错', () => {
|
||||
const result = toUpdateDepartmentInput({ name: '', leaderUserId: '', sortOrder: '' });
|
||||
expect(result.input).toBeNull();
|
||||
expect(result.errors.name).toBeTruthy();
|
||||
});
|
||||
|
||||
it('回显节点值(负责人 / 排序 / 名称)', () => {
|
||||
expect(
|
||||
departmentFormValues({
|
||||
id: PARENT_ID,
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leader: { id: LEADER_ID, name: '陈默' },
|
||||
status: 'active',
|
||||
sortOrder: 2,
|
||||
memberCount: 19,
|
||||
directMemberCount: 4,
|
||||
createdAt: '2026-11-01T00:00:00.000Z',
|
||||
}),
|
||||
).toEqual({ name: '内容生产中心', leaderUserId: LEADER_ID, sortOrder: '2' });
|
||||
expect(departmentFormValues(null)).toEqual(EMPTY_DEPARTMENT_FORM);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
type CreateDepartmentInput,
|
||||
createDepartmentInputSchema,
|
||||
type DepartmentNode,
|
||||
type UpdateDepartmentInput,
|
||||
updateDepartmentInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
export interface DepartmentFormValues {
|
||||
name: string;
|
||||
/** 空字符串表示不指定负责人 */
|
||||
leaderUserId: string;
|
||||
/** 数字字符串,空字符串表示使用默认排序 0 */
|
||||
sortOrder: string;
|
||||
}
|
||||
|
||||
export type DepartmentFormField = 'name' | 'sortOrder';
|
||||
|
||||
export type DepartmentFormErrors = Partial<Record<DepartmentFormField, string>>;
|
||||
|
||||
export const EMPTY_DEPARTMENT_FORM: DepartmentFormValues = {
|
||||
name: '',
|
||||
leaderUserId: '',
|
||||
sortOrder: '',
|
||||
};
|
||||
|
||||
export function departmentFormValues(node?: DepartmentNode | null): DepartmentFormValues {
|
||||
if (node === undefined || node === null) {
|
||||
return EMPTY_DEPARTMENT_FORM;
|
||||
}
|
||||
return {
|
||||
name: node.name,
|
||||
leaderUserId: node.leader?.id ?? '',
|
||||
sortOrder: String(node.sortOrder),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSortOrder(value: string): number | undefined | 'invalid' {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return 'invalid';
|
||||
}
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
function collectErrors(
|
||||
kind: 'create' | 'update',
|
||||
issues: readonly { path: (string | number)[]; message: string }[],
|
||||
): DepartmentFormErrors {
|
||||
const errors: DepartmentFormErrors = {};
|
||||
for (const issue of issues) {
|
||||
const field = issue.path[0];
|
||||
if (field === 'name' || field === 'sortOrder') {
|
||||
errors[field] ??= issue.message;
|
||||
}
|
||||
if (kind === 'create' && field === 'leaderUserId') {
|
||||
errors.name ??= '负责人不合法,请重新选择';
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export interface CreateDepartmentFormResult {
|
||||
input: CreateDepartmentInput | null;
|
||||
errors: DepartmentFormErrors;
|
||||
}
|
||||
|
||||
/** 新建部门 / 小组:与后端共用 createDepartmentInputSchema 校验 */
|
||||
export function toCreateDepartmentInput(
|
||||
values: DepartmentFormValues,
|
||||
parentId: string | null,
|
||||
): CreateDepartmentFormResult {
|
||||
const sortOrder = parseSortOrder(values.sortOrder);
|
||||
if (sortOrder === 'invalid') {
|
||||
return { input: null, errors: { sortOrder: '排序必须是非负整数' } };
|
||||
}
|
||||
const candidate = {
|
||||
name: values.name,
|
||||
parentId,
|
||||
...(values.leaderUserId === '' ? {} : { leaderUserId: values.leaderUserId }),
|
||||
...(sortOrder === undefined ? {} : { sortOrder }),
|
||||
};
|
||||
const parsed = createDepartmentInputSchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return { input: null, errors: collectErrors('create', parsed.error.issues) };
|
||||
}
|
||||
return { input: parsed.data, errors: {} };
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentFormResult {
|
||||
input: UpdateDepartmentInput | null;
|
||||
errors: DepartmentFormErrors;
|
||||
}
|
||||
|
||||
/** 编辑部门:上级关系创建后不可改,只提交名称 / 负责人 / 排序 */
|
||||
export function toUpdateDepartmentInput(values: DepartmentFormValues): UpdateDepartmentFormResult {
|
||||
const sortOrder = parseSortOrder(values.sortOrder);
|
||||
if (sortOrder === 'invalid') {
|
||||
return { input: null, errors: { sortOrder: '排序必须是非负整数' } };
|
||||
}
|
||||
const candidate = {
|
||||
name: values.name,
|
||||
leaderUserId: values.leaderUserId === '' ? null : values.leaderUserId,
|
||||
...(sortOrder === undefined ? {} : { sortOrder }),
|
||||
};
|
||||
const parsed = updateDepartmentInputSchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return { input: null, errors: collectErrors('update', parsed.error.issues) };
|
||||
}
|
||||
return { input: parsed.data, errors: {} };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { DepartmentNode } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildDepartmentTree,
|
||||
childMemberCount,
|
||||
childrenOf,
|
||||
departmentDisableBlocker,
|
||||
departmentEnableBlocker,
|
||||
filterTreeByStatus,
|
||||
} from './department-tree';
|
||||
|
||||
const ROOT_A = '01HZZZZZZZZZZZZZZZZZZZZZ01';
|
||||
const ROOT_B = '01HZZZZZZZZZZZZZZZZZZZZZ02';
|
||||
const CHILD_A1 = '01HZZZZZZZZZZZZZZZZZZZZZ03';
|
||||
const CHILD_A2 = '01HZZZZZZZZZZZZZZZZZZZZZ04';
|
||||
const DISABLED_ROOT = '01HZZZZZZZZZZZZZZZZZZZZZ05';
|
||||
|
||||
function department(
|
||||
id: string,
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
overrides: Partial<DepartmentNode> = {},
|
||||
): DepartmentNode {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
parentId,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 0,
|
||||
memberCount: 0,
|
||||
directMemberCount: 0,
|
||||
createdAt: '2026-11-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const NODES: DepartmentNode[] = [
|
||||
department(ROOT_A, '内容生产中心', null, { sortOrder: 1, memberCount: 19, directMemberCount: 4 }),
|
||||
department(CHILD_A2, '设计组', ROOT_A, { sortOrder: 2, memberCount: 3, directMemberCount: 3 }),
|
||||
department(CHILD_A1, '素材一组', ROOT_A, { sortOrder: 1, memberCount: 9, directMemberCount: 9 }),
|
||||
department(ROOT_B, '交付中心', null, { sortOrder: 2, memberCount: 14, directMemberCount: 14 }),
|
||||
department(DISABLED_ROOT, '历史部门', null, { status: 'disabled', sortOrder: 9 }),
|
||||
];
|
||||
|
||||
describe('部门树构建', () => {
|
||||
it('按 sortOrder、名称排序并挂载下级小组', () => {
|
||||
const model = buildDepartmentTree(NODES);
|
||||
expect(model.roots.map((root) => root.node.name)).toEqual([
|
||||
'内容生产中心',
|
||||
'交付中心',
|
||||
'历史部门',
|
||||
]);
|
||||
expect(model.roots[0]?.children.map((child) => child.name)).toEqual(['素材一组', '设计组']);
|
||||
expect(model.roots[1]?.children).toEqual([]);
|
||||
expect(childrenOf(model, ROOT_A).map((child) => child.id)).toEqual([CHILD_A1, CHILD_A2]);
|
||||
expect(model.byId.get(ROOT_A)?.name).toBe('内容生产中心');
|
||||
});
|
||||
|
||||
it('关闭「显示已停用」时过滤停用部门', () => {
|
||||
const model = buildDepartmentTree(NODES);
|
||||
const visible = filterTreeByStatus(model, false);
|
||||
expect(visible.roots.map((root) => root.node.name)).toEqual(['内容生产中心', '交付中心']);
|
||||
expect(filterTreeByStatus(model, true).nodes).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('含下级人数 = 总人数 - 直属人数', () => {
|
||||
const model = buildDepartmentTree(NODES);
|
||||
const root = model.byId.get(ROOT_A);
|
||||
expect(root === undefined ? null : childMemberCount(root)).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('停用 / 启用前置约束', () => {
|
||||
const root = department(ROOT_A, '内容生产中心', null, {
|
||||
memberCount: 19,
|
||||
directMemberCount: 0,
|
||||
});
|
||||
const child = department(CHILD_A1, '素材一组', ROOT_A, {
|
||||
memberCount: 9,
|
||||
directMemberCount: 9,
|
||||
});
|
||||
|
||||
it('有直属在职成员时禁止停用', () => {
|
||||
expect(departmentDisableBlocker(child, [])).toContain('9 名在职成员');
|
||||
});
|
||||
|
||||
it('一级部门存在启用中的下级小组时禁止停用', () => {
|
||||
expect(departmentDisableBlocker(root, [department(CHILD_A1, '素材一组', ROOT_A)])).toContain(
|
||||
'启用中的下级小组',
|
||||
);
|
||||
});
|
||||
|
||||
it('下级仍有在职成员时禁止停用', () => {
|
||||
const stoppedChildren = [department(CHILD_A1, '素材一组', ROOT_A, { status: 'disabled' })];
|
||||
expect(departmentDisableBlocker(root, stoppedChildren)).toContain('下级小组仍有');
|
||||
});
|
||||
|
||||
it('空部门允许停用;已停用返回提示', () => {
|
||||
const emptyRoot = { ...root, memberCount: 0, directMemberCount: 0 };
|
||||
expect(departmentDisableBlocker(emptyRoot, [])).toBeNull();
|
||||
expect(departmentDisableBlocker({ ...child, status: 'disabled' }, [])).toBe('部门已停用');
|
||||
});
|
||||
|
||||
it('上级停用时禁止启用下级小组', () => {
|
||||
const disabledChild = { ...child, status: 'disabled' as const };
|
||||
const disabledParent = { ...root, status: 'disabled' as const };
|
||||
expect(departmentEnableBlocker(disabledChild, disabledParent)).toContain('上级部门已停用');
|
||||
expect(departmentEnableBlocker(disabledChild, root)).toBeNull();
|
||||
expect(departmentEnableBlocker({ ...child, status: 'active' }, root)).toBe('部门已启用');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { DepartmentNode } from '@lingxi/shared';
|
||||
|
||||
export interface DepartmentTreeGroup {
|
||||
node: DepartmentNode;
|
||||
children: DepartmentNode[];
|
||||
}
|
||||
|
||||
export interface DepartmentTreeModel {
|
||||
roots: DepartmentTreeGroup[];
|
||||
nodes: DepartmentNode[];
|
||||
byId: Map<string, DepartmentNode>;
|
||||
}
|
||||
|
||||
function compareDepartments(left: DepartmentNode, right: DepartmentNode): number {
|
||||
if (left.sortOrder !== right.sortOrder) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
return left.name.localeCompare(right.name, 'zh-Hans-CN');
|
||||
}
|
||||
|
||||
/** 拍平列表 → 两级树(一级部门 + 直属小组),按 sortOrder、名称排序 */
|
||||
export function buildDepartmentTree(nodes: readonly DepartmentNode[]): DepartmentTreeModel {
|
||||
const sorted = [...nodes].sort(compareDepartments);
|
||||
const byId = new Map(sorted.map((node) => [node.id, node]));
|
||||
const childrenByParent = new Map<string, DepartmentNode[]>();
|
||||
const roots: DepartmentTreeGroup[] = [];
|
||||
for (const node of sorted) {
|
||||
if (node.parentId === null) {
|
||||
roots.push({ node, children: [] });
|
||||
continue;
|
||||
}
|
||||
const children = childrenByParent.get(node.parentId) ?? [];
|
||||
children.push(node);
|
||||
childrenByParent.set(node.parentId, children);
|
||||
}
|
||||
for (const root of roots) {
|
||||
root.children = childrenByParent.get(root.node.id) ?? [];
|
||||
}
|
||||
return { roots, nodes: sorted, byId };
|
||||
}
|
||||
|
||||
export function childrenOf(model: DepartmentTreeModel, departmentId: string): DepartmentNode[] {
|
||||
return model.nodes.filter((node) => node.parentId === departmentId);
|
||||
}
|
||||
|
||||
/** 父级 + 自身名称链(详情「上级部门」展示;一级部门返回空数组) */
|
||||
export function departmentPathNames(model: DepartmentTreeModel, departmentId: string): string[] {
|
||||
const node = model.byId.get(departmentId);
|
||||
if (node === undefined || node.parentId === null) {
|
||||
return [];
|
||||
}
|
||||
const parent = model.byId.get(node.parentId);
|
||||
return parent === undefined ? [] : [parent.name];
|
||||
}
|
||||
|
||||
/** 过滤已停用部门(含其下级);管理页默认展示全部,开关关闭时只看启用 */
|
||||
export function filterTreeByStatus(
|
||||
model: DepartmentTreeModel,
|
||||
showDisabled: boolean,
|
||||
): DepartmentTreeModel {
|
||||
if (showDisabled) {
|
||||
return model;
|
||||
}
|
||||
return buildDepartmentTree(model.nodes.filter((node) => node.status === 'active'));
|
||||
}
|
||||
|
||||
/** 下级小组在职成员数(含下级总人数 - 本部门直属人数) */
|
||||
export function childMemberCount(node: DepartmentNode): number {
|
||||
return Math.max(0, node.memberCount - node.directMemberCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用前置校验(与后端规则一致,用于按钮禁用与提示):
|
||||
* 本部门有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时不可停用。
|
||||
*/
|
||||
export function departmentDisableBlocker(
|
||||
node: DepartmentNode,
|
||||
children: readonly DepartmentNode[],
|
||||
): string | null {
|
||||
if (node.status === 'disabled') {
|
||||
return '部门已停用';
|
||||
}
|
||||
if (node.directMemberCount > 0) {
|
||||
return `部门下仍有 ${String(node.directMemberCount)} 名在职成员,请先调整成员所属部门`;
|
||||
}
|
||||
const activeChildren = children.filter((child) => child.status === 'active');
|
||||
if (activeChildren.length > 0) {
|
||||
return `存在 ${String(activeChildren.length)} 个启用中的下级小组,请先停用下级小组`;
|
||||
}
|
||||
const nestedMembers = childMemberCount(node);
|
||||
if (nestedMembers > 0) {
|
||||
return `下级小组仍有 ${String(nestedMembers)} 名在职成员,请先调整成员所属部门`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 启用前置校验:上级部门必须启用(小组不能挂到停用部门下) */
|
||||
export function departmentEnableBlocker(
|
||||
node: DepartmentNode,
|
||||
parent: DepartmentNode | null,
|
||||
): string | null {
|
||||
if (node.status === 'active') {
|
||||
return '部门已启用';
|
||||
}
|
||||
if (parent !== null && parent.status === 'disabled') {
|
||||
return '上级部门已停用,请先启用上级部门';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -22,7 +22,10 @@ const departments: DepartmentNode[] = [
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 1,
|
||||
memberCount: 19,
|
||||
directMemberCount: 4,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
@@ -30,7 +33,10 @@ const departments: DepartmentNode[] = [
|
||||
name: '素材一组',
|
||||
parentId: ROOT_ID,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 1,
|
||||
memberCount: 9,
|
||||
directMemberCount: 9,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
@@ -38,7 +44,10 @@ const departments: DepartmentNode[] = [
|
||||
name: '交付中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 2,
|
||||
memberCount: 14,
|
||||
directMemberCount: 14,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -24,6 +24,11 @@ const UserNewPage = lazy(async () => {
|
||||
return { default: module.UserNewPage };
|
||||
});
|
||||
|
||||
const DepartmentsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/departments/DepartmentsPage');
|
||||
return { default: module.DepartmentsPage };
|
||||
});
|
||||
|
||||
const RolesPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/roles/RolesPage');
|
||||
return { default: module.RolesPage };
|
||||
@@ -97,6 +102,14 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/departments',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.USER_VIEW}>
|
||||
{withSkeleton(<DepartmentsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
element: (
|
||||
|
||||
@@ -31,6 +31,10 @@ describe('路由常量与登录回跳', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('部门管理路由挂在管理中心下', () => {
|
||||
expect(ROUTES.system.departments).toBe('/system/departments');
|
||||
});
|
||||
|
||||
it('sanitizeRedirect 只接受站内路径', () => {
|
||||
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
|
||||
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ROUTES = {
|
||||
users: '/system/users',
|
||||
userNew: '/system/users/new',
|
||||
userDetail: '/system/users/:id',
|
||||
departments: '/system/departments',
|
||||
roles: '/system/roles',
|
||||
roleNew: '/system/roles/new',
|
||||
roleDetail: '/system/roles/:id',
|
||||
@@ -29,6 +30,7 @@ export type RoutePath =
|
||||
| typeof ROUTES.system.users
|
||||
| typeof ROUTES.system.userNew
|
||||
| typeof ROUTES.system.userDetail
|
||||
| typeof ROUTES.system.departments
|
||||
| typeof ROUTES.system.roles
|
||||
| typeof ROUTES.system.roleNew
|
||||
| typeof ROUTES.system.roleDetail
|
||||
|
||||
@@ -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": {
|
||||
"get": {
|
||||
"description": "memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true",
|
||||
"operationId": "DepartmentsController_tree",
|
||||
"parameters": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "include_disabled",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
@@ -238,7 +252,7 @@
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "部门树(两级,含成员数与负责人)",
|
||||
"summary": "部门树(两级)",
|
||||
"tags": [
|
||||
"identity-departments"
|
||||
]
|
||||
@@ -246,7 +260,7 @@
|
||||
},
|
||||
"/api/v1/departments": {
|
||||
"post": {
|
||||
"description": "最多两级,在小组下新建返回 42201",
|
||||
"description": "最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903",
|
||||
"operationId": "DepartmentsController_create",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
@@ -267,7 +281,8 @@
|
||||
},
|
||||
"/api/v1/departments/{id}": {
|
||||
"patch": {
|
||||
"operationId": "DepartmentsController_rename",
|
||||
"description": "上级关系创建后不可移动;负责人必须是启用用户",
|
||||
"operationId": "DepartmentsController_update",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -279,7 +294,49 @@
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "部门改名",
|
||||
"summary": "更新部门(名称 / 负责人 / 排序)",
|
||||
"tags": [
|
||||
"identity-departments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/departments/{id}/disable": {
|
||||
"post": {
|
||||
"description": "本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903",
|
||||
"operationId": "DepartmentsController_disable",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "停用部门",
|
||||
"tags": [
|
||||
"identity-departments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/departments/{id}/enable": {
|
||||
"post": {
|
||||
"description": "上级必须启用;已启用返回 40903",
|
||||
"operationId": "DepartmentsController_enable",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "启用部门",
|
||||
"tags": [
|
||||
"identity-departments"
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ulid } from '../ulid.js';
|
||||
import {
|
||||
accountSchema,
|
||||
changePasswordInputSchema,
|
||||
createDepartmentInputSchema,
|
||||
createUserInputSchema,
|
||||
DATA_SCOPE_TYPES,
|
||||
dataScopeSchema,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
roleSummarySchema,
|
||||
tokenPairSchema,
|
||||
transferCustomersInputSchema,
|
||||
updateDepartmentInputSchema,
|
||||
updateUserInputSchema,
|
||||
USER_LIST_SORTS,
|
||||
USER_LIST_STATUSES,
|
||||
@@ -183,7 +185,10 @@ describe('departmentNodeSchema', () => {
|
||||
name: '运营中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 0,
|
||||
memberCount: 0,
|
||||
directMemberCount: 0,
|
||||
createdAt: ISO,
|
||||
});
|
||||
expect(parsed.parentId).toBeNull();
|
||||
@@ -196,19 +201,25 @@ describe('departmentNodeSchema', () => {
|
||||
name: '投放一组',
|
||||
parentId: ulid(),
|
||||
leader: { id: ulid(), name: '王五' },
|
||||
status: 'active',
|
||||
sortOrder: 1,
|
||||
memberCount: 12,
|
||||
directMemberCount: 12,
|
||||
createdAt: ISO,
|
||||
});
|
||||
expect(parsed.leader?.name).toBe('王五');
|
||||
});
|
||||
|
||||
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名', () => {
|
||||
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名、非法状态', () => {
|
||||
const base = {
|
||||
id: ulid(),
|
||||
name: '运营中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
status: 'active',
|
||||
sortOrder: 0,
|
||||
memberCount: 3,
|
||||
directMemberCount: 3,
|
||||
createdAt: ISO,
|
||||
};
|
||||
expect(() => departmentNodeSchema.parse({ ...base, memberCount: -1 })).toThrow();
|
||||
@@ -217,6 +228,26 @@ describe('departmentNodeSchema', () => {
|
||||
expect(() =>
|
||||
departmentNodeSchema.parse({ ...base, leader: { id: ulid(), name: '' } }),
|
||||
).toThrow();
|
||||
expect(() => departmentNodeSchema.parse({ ...base, status: 'offline' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('部门写接口契约', () => {
|
||||
it('新建部门:名称必填、parentId 可空、排序与负责人可选', () => {
|
||||
const parsed = createDepartmentInputSchema.parse({ name: '运营中心' });
|
||||
expect(parsed.parentId).toBeNull();
|
||||
expect(() => createDepartmentInputSchema.parse({ name: '' })).toThrow();
|
||||
expect(() =>
|
||||
createDepartmentInputSchema.parse({ name: '运营中心', parentId: 'bad' }),
|
||||
).toThrow();
|
||||
expect(() => createDepartmentInputSchema.parse({ name: '运营中心', unknown: true })).toThrow();
|
||||
});
|
||||
|
||||
it('更新部门:只允许名称 / 负责人 / 排序,未知字段拒绝', () => {
|
||||
const parsed = updateDepartmentInputSchema.parse({ name: '运营中心', sortOrder: 2 });
|
||||
expect(parsed.sortOrder).toBe(2);
|
||||
expect(() => updateDepartmentInputSchema.parse({ parentId: ulid() })).toThrow();
|
||||
expect(() => updateDepartmentInputSchema.parse({ sortOrder: -1 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,26 @@ export type RoleSummary = z.infer<typeof roleSummarySchema>;
|
||||
// 部门
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH) */
|
||||
/** 部门状态:停用后不出现在新建 / 编辑用户的部门选择中,历史数据保留 */
|
||||
export const DEPARTMENT_STATUSES = ['active', 'disabled'] as const;
|
||||
|
||||
export const departmentStatusSchema = z.enum(DEPARTMENT_STATUSES, {
|
||||
errorMap: () => ({ message: '部门状态只能是 active 或 disabled' }),
|
||||
});
|
||||
|
||||
export type DepartmentStatus = z.infer<typeof departmentStatusSchema>;
|
||||
|
||||
/** 部门名称:1–20 个字符(同级唯一由服务端校验) */
|
||||
export const departmentNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入部门名称')
|
||||
.max(20, '部门名称最多 20 个字符');
|
||||
|
||||
/**
|
||||
* 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH)。
|
||||
* memberCount 含下级成员,directMemberCount 仅本部门直属成员。
|
||||
*/
|
||||
export const departmentNodeSchema = z.object({
|
||||
id: ulidSchema,
|
||||
name: z.string().min(1, '部门名称不能为空'),
|
||||
@@ -93,12 +112,48 @@ export const departmentNodeSchema = z.object({
|
||||
name: z.string().min(1, '负责人姓名不能为空'),
|
||||
})
|
||||
.nullable(),
|
||||
status: departmentStatusSchema,
|
||||
sortOrder: z.number().int('排序必须是整数').min(0, '排序不能为负数'),
|
||||
memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'),
|
||||
directMemberCount: z.number().int('直属人数必须是整数').nonnegative('直属人数不能为负数'),
|
||||
createdAt: isoDateTimeSchema,
|
||||
});
|
||||
|
||||
export type DepartmentNode = z.infer<typeof departmentNodeSchema>;
|
||||
|
||||
/** 新建部门 / 小组:parentId 为 null 建一级部门,否则在该部门下建小组 */
|
||||
export const createDepartmentInputSchema = z
|
||||
.object({
|
||||
name: departmentNameSchema,
|
||||
parentId: ulidSchema.nullable().default(null),
|
||||
leaderUserId: ulidSchema.nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).max(9999).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateDepartmentInput = z.infer<typeof createDepartmentInputSchema>;
|
||||
|
||||
/** 更新部门:只允许改名称 / 负责人 / 排序;上级关系创建后不可移动 */
|
||||
export const updateDepartmentInputSchema = z
|
||||
.object({
|
||||
name: departmentNameSchema.optional(),
|
||||
leaderUserId: ulidSchema.nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).max(9999).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UpdateDepartmentInput = z.infer<typeof updateDepartmentInputSchema>;
|
||||
|
||||
/** 部门树查询:管理页需要包含已停用部门,普通选择器默认只看启用 */
|
||||
export const departmentTreeQuerySchema = z.object({
|
||||
include_disabled: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
});
|
||||
|
||||
export type DepartmentTreeQuery = z.infer<typeof departmentTreeQuerySchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user