# 钉钉导入标记班级 — 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在钉钉组织导入抽屉中支持标记部门为班级,导入时自动创建班级并建立师生关联。 **Architecture:** 前端在 IntegrationConfig 抽屉中添加部门级「标为班级」按钮和 Modal 表单;后端 `importDingTalkUsers` 方法接收可选的 `classes[]` 参数,在单事务中先建班、再导人、最后建关联。导入时勾选的用户分配角色后即为老师,所有老师统一写入 `ClassTeacher`(`roleType='teacher'`),班级对老师为多对多。 **Tech Stack:** React 19 + Ant Design 6 (前端), NestJS 11 + TypeORM 0.3 (后端), SQLite/MySQL ## Global Constraints - 规范约束自 `CLAUDE.md`(恭学教育学生管理系统 — 项目约束) - 前端编码 agent 需注入 `ui-ux-pro-max`(Ant Design 6 交互规范)和 `vercel-react-best-practices`(性能优化) - 后端编码 agent 需注入 `nestjs-best-practices` - 所有编辑遵循现有 NestJS 模块结构 - 敏感信息脱敏规则照旧(不涉及本次改动) - 遵循 skil `ponytail` full 级别约束:最简实现,不引入新依赖,不创建不必要的抽象 --- ### Task 1: 后端 — 扩展 DTO 和钉钉接口返回 deptIds **Files:** - Modify: `apps/server/src/sync/dto/import-users.dto.ts` - Modify: `apps/server/src/integration/dingtalk.service.ts:486-499` **Interfaces:** - Consumes: 现有 `ImportUserItemDto`, `DingOrgTreeNodeWithUsers` - Produces: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds`, `DingOrgTreeNodeWithUsers.users[].deptIds` - [ ] **Step 1: 扩展 ImportUsersDto,新增 ImportClassItemDto** 编辑 `apps/server/src/sync/dto/import-users.dto.ts`,在现有 `ImportUserItemDto` 中加 `dingDeptIds` 字段,新增 `ImportClassItemDto` 和 `ImportUsersDto.classes`: ```ts import { IsArray, IsString, IsNumber, IsOptional, IsEnum, ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; export class ImportUserItemDto { @IsString() dingUserId: string; @IsString() name: string; @IsString() mobile: string; @IsOptional() @IsNumber() roleId: number | null; @IsArray() @IsNumber({}, { each: true }) dingDeptIds: number[]; } export class ImportClassItemDto { @IsNumber() deptId: number; @IsString() name: string; @IsString() code: string; @IsString() classType: string; @IsOptional() @IsString() startDate?: string; @IsOptional() @IsString() endDate?: string; @IsOptional() @IsNumber() maxStudents?: number; @IsOptional() @IsString() notes?: string; } export class ImportUsersDto { @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => ImportClassItemDto) classes?: ImportClassItemDto[]; @IsArray() @ValidateNested({ each: true }) @Type(() => ImportUserItemDto) users: ImportUserItemDto[]; } ``` - [ ] **Step 2: dingtalk.service.ts — fetchOrgTreeWithUsers 返回 deptIds** 编辑 `apps/server/src/integration/dingtalk.service.ts`,在 `fetchOrgTreeWithUsers` 方法中保留 `dept_id_list` 到每个 user。 找到第 493-498 行的 users mapping,改为: ```ts // Before dedup: collect deptIds per user const userDeptMap = new Map(); nodes.push({ id: detail.dept_id, name: detail.name, parentId: detail.parent_id, children: [], users: dingUsers.map((u) => ({ userid: u.userid, name: u.name, mobile: u.mobile, })), }); // Record which departments each user belongs to for (const u of dingUsers) { if (!userDeptMap.has(u.userid)) { userDeptMap.set(u.userid, []); } userDeptMap.get(u.userid)!.push(detail.dept_id); } ``` 然后在去重循环后(第 503-509 行),为每个 user 附加 deptIds: ```ts for (const node of nodes) { node.users = node.users .filter((u) => { if (seenUserIds.has(u.userid)) return false; seenUserIds.add(u.userid); return true; }) .map((u) => ({ ...u, deptIds: userDeptMap.get(u.userid) || [], })); } ``` 同步更新 `DingOrgTreeNodeWithUsers` interface: ```ts export interface DingOrgTreeNodeWithUsers { id: number; name: string; parentId: number; children: DingOrgTreeNodeWithUsers[]; users: Array<{ userid: string; name: string; mobile: string; deptIds: number[]; }>; } ``` - [ ] **Step 3: 编译验证** ```bash cd apps/server && npx tsc --noEmit ``` Expected: no new type errors from the modified files. - [ ] **Step 4: Commit** ```bash git add apps/server/src/sync/dto/import-users.dto.ts apps/server/src/integration/dingtalk.service.ts git commit -m "feat(sync): add ImportClassItemDto and expose deptIds in org-tree-with-users" ``` --- ### Task 2: 后端 — 改造 importDingTalkUsers 支持班级关联 **Files:** - Modify: `apps/server/src/sync/sync.service.ts:122-199` - Modify: `apps/server/src/sync/sync.module.ts:16-34` **Interfaces:** - Consumes: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds` (from Task 1) - Produces: 改造后的 `importDingTalkUsers(classes?: ImportClassItemDto[], users: ImportUserItemDto[])`,返回增加 `classCount` - [ ] **Step 1: sync.module.ts — 注入 Class 和 ClassStudent Repository** SyncModule 当前未导入 `Class` 和 `ClassStudent` entity。编辑 `apps/server/src/sync/sync.module.ts`: ```ts import { SyncLog, SyncState, UserDingMapping, ClassSchedule, Department, UserDepartment, ClassTeacher, User, Student, Role, Class, // 新增 ClassStudent, // 新增 } from '../entities'; ``` 并在 `TypeOrmModule.forFeature` 数组中添加 `Class, ClassStudent`。 - [ ] **Step 2: sync.service.ts — constructor 注入新 repo** 编辑 `apps/server/src/sync/sync.service.ts`: ```ts import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; import type { ImportClassItemDto } from './dto/import-users.dto'; ``` Constructor 添加: ```ts @InjectRepository(Class) private readonly classRepo: Repository, @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, ``` 更新 `ImportUserDto` 接口以包含 `dingDeptIds`: ```ts export interface ImportUserDto { dingUserId: string; name: string; mobile: string; roleId: number | null; dingDeptIds: number[]; } ``` - [ ] **Step 3: 改写 importDingTalkUsers 方法签名和逻辑** 将方法签名改为: ```ts async importDingTalkUsers( users: ImportUserDto[], classes?: ImportClassItemDto[], ): Promise<{ teacherCount: number; studentCount: number; classCount: number; skipped: number; warnings: string[]; }> ``` 完整方法体替换为单事务版本: ```ts async importDingTalkUsers( users: ImportUserDto[], classes?: ImportClassItemDto[], ): Promise<{ teacherCount: number; studentCount: number; classCount: number; skipped: number; warnings: string[]; }> { const classItems = classes ?? []; const warnings: string[] = []; // 预检查班级编码重复 if (classItems.length > 0) { const codes = classItems.map((c) => c.code); const existing = await this.classRepo.find({ where: codes.map((code) => ({ code } as any)) }); if (existing.length > 0) { const dup = existing.map((c) => c.code).join(', '); throw new BadRequestException(`班级编码已存在: ${dup}`); } } let teacherCount = 0; let studentCount = 0; let skipped = 0; await this.dataSource.transaction(async (manager) => { // 1. 创建班级 const deptClassMap = new Map(); // deptId -> classId for (const c of classItems) { const cls = manager.create(Class, { name: c.name, code: c.code, classType: c.classType, startDate: c.startDate ?? null, endDate: c.endDate ?? null, maxStudents: c.maxStudents ?? 0, notes: c.notes ?? null, } as any); await manager.save(cls); deptClassMap.set(c.deptId, cls.id); } // 2. 导入用户(逐用户) for (const u of users) { const existingMapping = await manager.findOne(UserDingMapping, { where: { dingUserId: u.dingUserId }, }); if (existingMapping) { skipped++; continue; } const username = `dd_${u.dingUserId}`; const passwordHash = await bcrypt.hash('123456', 10); const user = manager.create(User, { username, name: u.name, passwordHash, isActive: true, }); await manager.save(user); let isTeacher = false; let isHeadTeacher = false; if (u.roleId != null) { const role = await manager.findOne(Role, { where: { id: u.roleId } }); if (!role) { throw new BadRequestException(`角色 id=${u.roleId} 不存在`); } user.roles = [role]; let isTeacher = false; if (u.roleId != null) { const role = await manager.findOne(Role, { where: { id: u.roleId } }); if (!role) { throw new BadRequestException(`角色 id=${u.roleId} 不存在`); } user.roles = [role]; await manager.save(user); isTeacher = true; teacherCount++; } else { const student = manager.create(Student, { name: u.name, phone: u.mobile || undefined, userId: user.id, status: 'active', }); await manager.save(student); studentCount++; } // 钉钉映射 const mapping = manager.create(UserDingMapping, { dingUserId: u.dingUserId, userId: user.id, dingName: u.name, dingMobile: u.mobile, }); await manager.save(mapping); // 3. 建立班级关联 if (classItems.length > 0 && u.dingDeptIds?.length > 0) { for (const deptId of u.dingDeptIds) { const classId = deptClassMap.get(deptId); if (!classId) continue; if (isTeacher) { const ct = manager.create(ClassTeacher, { classId, userId: user.id, roleType: 'teacher', } as any); await manager.save(ct); } else { const cs = manager.create(ClassStudent, { classId, studentId: (await manager.findOne(Student, { where: { userId: user.id } }))?.id, status: 'active', } as any); await manager.save(cs); } } } } // 4. 检查空班级 for (const [deptId, classId] of deptClassMap) { const tc = await manager.count(ClassTeacher, { where: { classId } }); const sc = await manager.count(ClassStudent, { where: { classId } }); if (tc === 0 && sc === 0) { const cls = await manager.findOne(Class, { where: { id: classId } }); warnings.push(`班级 "${cls?.name}" (deptId=${deptId}) 无任何师生`); } } }); this.logger.log( `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`, ); return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings }; } ``` 需要新增 import: ```ts import { BadRequestException } from '@nestjs/common'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; ``` - [ ] **Step 4: 编译验证** ```bash cd apps/server && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 5: 更新 sync.controller.ts 调用方式** `apps/server/src/sync/sync.controller.ts` 第 57 行: ```ts const result = await this.syncService.importDingTalkUsers(body.users); ``` 改为: ```ts const result = await this.syncService.importDingTalkUsers(body.users, body.classes); ``` - [ ] **Step 6: Commit** ```bash git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.controller.ts git commit -m "feat(sync): importDingTalkUsers supports class creation and teacher/student linking" ``` --- ### Task 3: 前端 — IntegrationConfig 树节点和班级标记 Modal **Files:** - Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx` **Interfaces:** - Consumes: 改造后的 `POST /sync/dingtalk/import-users` (classes + users),DingOrgTreeNodeExt 新增 deptIds - Produces: 树中部门节点可标为班级,Modal 表单,导入 payload 含 classes **Skills to load before coding:** - `ui-ux-pro-max` — Ant Design 6 组件选型、交互细节 - `vercel-react-best-practices` — memo、useMemo 避免无意义重渲染 - [ ] **Step 1: 扩展前端类型定义** 在 `IntegrationConfig/index.tsx` 的 interface 定义区域,修改 `DingOrgTreeNodeExt`: ```ts interface DingOrgTreeNodeExt { id: number; name: string; parentId: number; children: DingOrgTreeNodeExt[]; users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>; } ``` 新增 class mark 表单类型和状态: ```ts interface ClassMarkForm { deptId: number; name: string; code: string; classType: string; startDate?: string; endDate?: string; maxStudents?: number; notes?: string; } ``` 在组件 state 区域(第 91-101 行附近)新增: ```ts const [classMarks, setClassMarks] = useState>({}); const [classModalOpen, setClassModalOpen] = useState(false); const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null); const [classForm] = Form.useForm(); ``` - [ ] **Step 2: 标记班级 Modal 组件** 在组件内部(`handleImportUsers` 之前)添加 Modal 处理函数: ```ts const openClassModal = (deptId: number, deptName: string) => { const existing = classMarks[deptId]; if (existing) { classForm.setFieldsValue(existing); } else { classForm.setFieldsValue({ deptId, name: deptName, code: '', classType: 'culture', }); } setClassModalDept({ id: deptId, name: deptName }); setClassModalOpen(true); }; const handleClassModalOk = async () => { const values = await classForm.validateFields(); setClassMarks((prev) => ({ ...prev, [values.deptId]: values, })); setClassModalOpen(false); setClassModalDept(null); }; const handleClassModalCancel = () => { setClassModalOpen(false); setClassModalDept(null); }; ``` Modal JSX(放在 Drawer 之前或之后): ```tsx