背景:
- 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
+3237
-125
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user