diff --git a/docs/superpowers/plans/2026-07-10-fix-student-import-name.md b/docs/superpowers/plans/2026-07-10-fix-student-import-name.md new file mode 100644 index 0000000..473ad64 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-fix-student-import-name.md @@ -0,0 +1,390 @@ +# 修复学生导入姓名与账号 — 实现计划 + +> **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:** 修复批量导入学生时 name 填占位符 `dd_xxx`、phone 未设置的问题,改为使用前端传入的真实姓名和手机号。 + +**Architecture:** 前端已有钉钉用户 name/mobile 数据,导入时随请求传给后端,后端直接用于创建 Student,零额外钉钉 API 调用。 + +**Tech Stack:** NestJS + TypeORM + React + Ant Design + +## Global Constraints + +- 不改 Student entity / 数据库 schema +- 不改 DingTalkService +- 不改 StudentDingMapping +- `tsc --noEmit` 编译通过 + +--- + +### Task 1: 后端 DTO — 字段改名 + +**Files:** +- Modify: `apps/server/src/classes/dto/class.dto.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: `CreateClassDto.users`, `BatchImportStudentsDto.users` + +- [ ] **Step 1: 改 BatchImportStudentsDto** + +`class.dto.ts` 第 136-141 行: + +```typescript +export class BatchImportStudentsDto { + @IsArray() + @IsString({ each: true }) + @ArrayNotEmpty() + dingUserIds: string[]; +} +``` + +改为: + +```typescript +export class BatchImportStudentsDto { + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => ImportUserItem) + users: ImportUserItem[]; +} + +export class ImportUserItem { + @IsString() @IsNotEmpty() + dingUserId: string; + + @IsString() @IsNotEmpty() + name: string; + + @IsOptional() @IsString() + mobile?: string; +} +``` + +- [ ] **Step 2: 改 CreateClassDto** + +第 43-46 行,`dingUserIds` 字段: + +```typescript + @IsArray() + @IsString({ each: true }) + @IsOptional() + dingUserIds?: string[]; +``` + +改为: + +```typescript + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ImportUserItem) + users?: ImportUserItem[]; +``` + +- [ ] **Step 3: 补 import** + +文件头部 import 行(第 1 行),追加 `ValidateNested`: + +```typescript +import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator'; +``` + +- [ ] **Step 4: 编译验证** + +```bash +cd apps/server && npx tsc --noEmit 2>&1 | head -20 +``` + +预期:Pass(如果后续 Task 没改完会有 type error,属于预期) + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/classes/dto/class.dto.ts +git commit -m "feat: change dingUserIds to users array with name/mobile in DTOs" +``` + +--- + +### Task 2: 后端 Service — 使用真实 name 和 phone + +**Files:** +- Modify: `apps/server/src/classes/classes.service.ts:96-191` + +**Interfaces:** +- Consumes: `BatchImportStudentsDto.users`, `CreateClassDto.users` (from Task 1) +- Produces: updated `batchImportStudents` signature + +- [ ] **Step 1: 改 create() 方法** + +第 96-128 行,两处 `dingUserIds` → `users`: + +```typescript + async create(dto: CreateClassDto) { + const { studentIds, teachers, users, ...classData } = dto; + … + // batch import students by dingUserIds + if (users?.length) { + await this.batchImportStudents(saved.id, users); + } + … + } +``` + +- [ ] **Step 2: 改 batchImportStudents 签名和逻辑** + +第 131-191 行: + +```typescript + async batchImportStudents(classId: number, users: Array<{ + dingUserId: string; name: string; mobile?: string; + }>): Promise<{ imported: number; skipped: number }> { + const classEntity = await this.classRepo.findOne({ where: { id: classId } }); + if (!classEntity) throw new NotFoundException('班级不存在'); + + if (users.length === 0) return { imported: 0, skipped: 0 }; + + const dingUserIds = users.map(u => u.dingUserId); + + // 1. Fetch all existing ding mappings in one query + const existingMappings = await this.studentDingMappingRepo.find({ + where: { dingUserId: In(dingUserIds) }, + }); + const dingToStudentId = new Map(existingMappings.map(m => [m.dingUserId, m.studentId])); + + // 2. Batch create students for new dingUserIds + const newUsers = users.filter(u => !dingToStudentId.has(u.dingUserId)); + if (newUsers.length > 0) { + const newStudents = newUsers.map(u => + this.studentRepo.create({ + name: u.name, + phone: u.mobile || `dt_${u.dingUserId}`, + status: 'active', + }) + ); + const savedStudents = await this.studentRepo.save(newStudents); + + const newMappings = savedStudents.map((s, i) => + this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }) + ); + await this.studentDingMappingRepo.save(newMappings); + + for (let i = 0; i < newUsers.length; i++) { + dingToStudentId.set(newUsers[i].dingUserId, savedStudents[i].id); + } + } + + // 3. Fetch existing class-student links in one query + const allStudentIds = Array.from(dingToStudentId.values()); + … // 不变 + } +``` + +- [ ] **Step 3: 编译验证** + +```bash +cd apps/server && npx tsc --noEmit 2>&1 | head -20 +``` + +预期:Pass + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/classes/classes.service.ts +git commit -m "fix: use real name and phone from frontend when importing students" +``` + +--- + +### Task 3: 后端 Controller — 传参修正 + +**Files:** +- Modify: `apps/server/src/classes/classes.controller.ts:93-98` + +**Interfaces:** +- Consumes: `BatchImportStudentsDto.users` (from Task 1) +- Produces: nothing new + +- [ ] **Step 1: 改 controller 传参** + +第 97 行: + +```typescript + return this.service.batchImportStudents(+id, dto.users); +``` + +- [ ] **Step 2: 编译验证** + +```bash +cd apps/server && npx tsc --noEmit 2>&1 | head -20 +``` + +预期:Pass + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/classes/classes.controller.ts +git commit -m "fix: pass dto.users instead of dto.dingUserIds in controller" +``` + +--- + +### Task 4: 前端 — 传用户信息而非仅 ID + +**Files:** +- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx` + +**Interfaces:** +- Consumes: `orgTree` (state), `checkedKeys` (state) +- Produces: updated API call payloads + +- [ ] **Step 1: 写 extractCheckedUsers 工具函数** + +在 `handleJoinClass` 上方插入: + +```typescript + const extractCheckedUsers = useCallback((): Array<{ dingUserId: string; name: string; mobile?: string }> => { + const result: Array<{ dingUserId: string; name: string; mobile?: string }> = []; + const walk = (nodes: DingOrgTreeNodeExt[]) => { + for (const node of nodes) { + for (const u of node.users) { + if (checkedKeys.includes(`user-${u.userid}`)) { + result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined }); + } + } + walk(node.children); + } + }; + walk(orgTree); + return result; + }, [checkedKeys, orgTree]); +``` + +- [ ] **Step 2: 改 handleJoinClass** + +第 212-231 行,旧代码: + +```typescript + const handleJoinClass = async () => { + if (selectedClassId === null) return message.warning('请先选择一个班级'); + const userIds = checkedKeys + .filter((k) => String(k).startsWith('user-')) + .map((k) => String(k).replace('user-', '')); + if (userIds.length === 0) return message.warning('请勾选要导入的用户'); + + setImporting(true); + try { + const res = await api.post(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds }); + message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); + setCheckedKeys([]); + setSelectedClassId(null); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '导入失败'); + } finally { + setImporting(false); + } + }; +``` + +改为: + +```typescript + const handleJoinClass = async () => { + if (selectedClassId === null) return message.warning('请先选择一个班级'); + const users = extractCheckedUsers(); + if (users.length === 0) return message.warning('请勾选要导入的用户'); + + setImporting(true); + try { + const res = await api.post(`/classes/${selectedClassId}/students/import`, { users }); + message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); + setCheckedKeys([]); + setSelectedClassId(null); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '导入失败'); + } finally { + setImporting(false); + } + }; +``` + +- [ ] **Step 3: 改 handleCreateClass** + +第 233-251 行: + +```typescript + const handleCreateClass = async () => { + try { + const values = await classForm.validateFields(); + const users = extractCheckedUsers(); + await api.post('/classes', { ...values, users }); + message.success('班级创建成功'); + setClassModalOpen(false); + classForm.resetFields(); + setCheckedKeys([]); + fetchClasses(); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '创建失败'); + } finally { + setImporting(false); + } + }; +``` + +(关键变更:`dingUserIds: userIds` → `users`,删除手动的 `.filter().map()` 逻辑) + +- [ ] **Step 4: 前端编译验证** + +```bash +cd apps/admin && npx tsc --noEmit 2>&1 | head -20 +``` + +预期:Pass + +- [ ] **Step 5: Commit** + +```bash +git add apps/admin/src/pages/IntegrationConfig/index.tsx +git commit -m "fix: pass user name and mobile to backend when importing students" +``` + +--- + +### Task 5: 端到端验证 + +- [ ] **Step 1: 后端全量编译** + +```bash +cd apps/server && npx tsc --noEmit +``` + +预期:0 errors + +- [ ] **Step 2: 前端全量编译** + +```bash +cd apps/admin && npx tsc --noEmit +``` + +预期:0 errors + +- [ ] **Step 3: 后端测试** + +```bash +cd apps/server && npm test 2>&1 | tail -20 +``` + +预期:现有测试全部通过 + +- [ ] **Step 4: Commit(如有遗漏)** + +```bash +git status +```