diff --git a/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md b/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md new file mode 100644 index 0000000..c6fcadb --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md @@ -0,0 +1,805 @@ +# 钉钉导入标记班级 — 实现计划 + +> **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 + +
+ + + + + + + + +