# Student Ding Mapping + Drawer Batch Operations — Implementation Plan > **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. > **Required skills per agent:** `superpowers:vercel-react-best-practices`, `superpowers:ui-ux-pro-max` **Goal:** Replace UserDingMapping with StudentDingMapping, rewrite syncAll to create Students directly, add class batch-import endpoint, rebuild Drawer with checkable Tree + class list. **Architecture:** Student becomes independent from User — sync creates Student + StudentDingMapping directly. Frontend Drawer uses Ant Design `` with left-right split layout for batch user selection and class operations. **Tech Stack:** NestJS 11 + TypeORM 0.3 + Ant Design 6 ## Global Constraints - Student is independent of User (no userId, no RBAC) - StudentDingMapping replaces UserDingMapping everywhere - fetchOrgTree (dept picker) is NOT modified - syncAll still syncs all departments — only user handling changes - Depth filtering (getDeptDepthMap) is removed entirely --- ### Task 1: Create StudentDingMapping entity + swap in all modules **Files:** - Create: `apps/server/src/entities/student-ding-mapping.entity.ts` - Modify: `apps/server/src/entities/index.ts` - Modify: `apps/server/src/app.module.ts` - Modify: `apps/server/src/integration/dingtalk.service.ts` — import + repo injection - Modify: `apps/server/src/integration/integration.module.ts` — TypeOrmModule.forFeature - Modify: `apps/server/src/sync/sync.service.ts` — import + repo injection - Modify: `apps/server/src/sync/sync.module.ts` — TypeOrmModule.forFeature - Modify: `apps/server/src/sync/schedule-sync.service.ts` — import + repo injection - Modify: `apps/server/src/attendance/attendance-import.service.ts` — import + repo injection - Modify: `apps/server/src/attendance/attendance.module.ts` — TypeOrmModule.forFeature - Modify: `apps/server/src/attendance/attendance.service.ts` — import + repo injection - Modify: `apps/server/src/rbac/rbac.service.ts` — import + repo injection - Modify: `apps/server/src/rbac/rbac.module.ts` — TypeOrmModule.forFeature **Interfaces:** - Produces: `StudentDingMapping` entity with `id`, `dingUserId`(unique), `studentId`(unique), `student`(ManyToOne), `createdAt` - Note: rbac controller endpoints (getUserDingMappings etc.) removed in Task 4 - [ ] **Step 1: Create `student-ding-mapping.entity.ts`** ```typescript import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, } from 'typeorm'; import { Student } from './student.entity'; @Entity('student_ding_mapping') export class StudentDingMapping { @PrimaryGeneratedColumn() id: number; @Column({ name: 'ding_user_id', length: 100, unique: true }) dingUserId: string; @Column({ name: 'student_id', type: 'integer', unique: true }) studentId: number; @ManyToOne(() => Student, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'student_id' }) student: Student; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` - [ ] **Step 2: Swap exports in `entities/index.ts`** Remove: `export { UserDingMapping } from './user-ding-mapping.entity';` Add: `export { StudentDingMapping } from './student-ding-mapping.entity';` - [ ] **Step 3: Swap in `app.module.ts`** In the import from `'./entities'`: replace `UserDingMapping` with `StudentDingMapping`. In the `TypeOrmModule.forRoot` entities array: same replacement. - [ ] **Step 4: Swap in every module file** For each file listed above, replace mechanically: - `UserDingMapping` → `StudentDingMapping` - `user_ding_mapping` → `student_ding_mapping` - `mappingRepo` / `userDingMappingRepo` → `studentDingMappingRepo` - [ ] **Step 5: Delete `user-ding-mapping.entity.ts`** - [ ] **Step 6: Verify compile** ```bash cd apps/server && npx tsc --noEmit ``` Expected: zero new errors (pre-existing spec-file errors ignored). - [ ] **Step 7: Commit** ```bash git add apps/server/src/entities/ apps/server/src/app.module.ts apps/server/src/integration/ apps/server/src/sync/ apps/server/src/attendance/ apps/server/src/rbac/ git commit -m "refactor: replace UserDingMapping with StudentDingMapping entity" ``` --- ### Task 2: Rewrite syncAll to create Student directly **Files:** - Modify: `apps/server/src/integration/dingtalk.service.ts` — `syncAll` and `syncOneUser` **Interfaces:** - Consumes: `StudentDingMapping` entity (Task 1) - Produces: same `{ deptCount, userCount }` return type - [ ] **Step 1: Rewrite `syncOneUser`** Replace existing `syncOneUser` (lines ~553-624) with: ```typescript private async syncOneUser(du: { userid: string; name: string; mobile: string; }): Promise { let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId: du.userid }, }); if (mapping) { const student = await this.studentRepo.findOne({ where: { id: mapping.studentId }, }); if (student) { student.name = du.name; if (du.mobile) student.phone = du.mobile; await this.studentRepo.save(student); } return; } const student = this.studentRepo.create({ name: du.name, phone: du.mobile || undefined, status: 'active', }); await this.studentRepo.save(student); mapping = this.studentDingMappingRepo.create({ dingUserId: du.userid, studentId: student.id, }); await this.studentDingMappingRepo.save(mapping); } ``` Remove unused imports: `bcrypt`, `User`, `userRepo` injection. `studentDingMappingRepo` already injected from Task 1. - [ ] **Step 2: Verify compile** ```bash cd apps/server && npx tsc --noEmit ``` - [ ] **Step 3: Commit** ```bash git add apps/server/src/integration/dingtalk.service.ts git commit -m "refactor: syncAll creates Student + StudentDingMapping directly" ``` --- ### Task 3: Revert deepest-2-levels filtering **Files:** - Modify: `apps/server/src/integration/dingtalk.service.ts` — `fetchOrgTreeWithUsers` + remove `getDeptDepthMap` - [ ] **Step 1: Remove `getDeptDepthMap` method** (the ~37 lines added in commit 0509175) - [ ] **Step 2: Restore `fetchOrgTreeWithUsers` loop** Remove depthMap/maxDepth/filteredIds. Restore: ```typescript const deptIds = await this.getAllDeptIds(token, rootDeptId); ``` And `for (let i = 0; i < deptIds.length; i++)` instead of `filteredIds`. - [ ] **Step 3: Restore tree assembly condition** Add back `&& node.id !== rootDeptId` in the root-detection line. - [ ] **Step 4: Verify compile + commit** ```bash cd apps/server && npx tsc --noEmit git add apps/server/src/integration/dingtalk.service.ts git commit -m "revert: remove deepest-2-levels filter, restore full org tree" ``` --- ### Task 4: Remove User-based import + RBAC UserDingMapping endpoints **Files:** - Modify: `apps/server/src/sync/sync.service.ts` — remove `importDingTalkUsers`, `ImportUserDto` - Modify: `apps/server/src/sync/sync.controller.ts` — remove `POST /dingtalk/import-users` - Modify: `apps/server/src/sync/dto/import-users.dto.ts` — remove `ImportUsersDto` - Modify: `apps/server/src/rbac/rbac.service.ts` — remove `getUserDingMappings`, `createUserDingMapping`, `deleteUserDingMapping`, unbound-users query - Modify: `apps/server/src/rbac/rbac.controller.ts` — remove corresponding endpoints + imports - Modify: `apps/server/src/rbac/dto/rbac.dto.ts` — remove `CreateUserDingMappingDto` - [ ] **Step 1: Remove importDingTalkUsers from sync.service.ts** Delete the entire `importDingTalkUsers` method and the `ImportUserDto` interface. Remove now-unused imports: `Role`, `bcrypt`, `ClassTeacher`, `ClassStudent`, `Department`, `ClassEntity`, `BadRequestException`. - [ ] **Step 2: Remove import endpoint from sync.controller.ts** Delete the `POST /dingtalk/import-users` handler and `ImportUsersDto` import. - [ ] **Step 3: Remove RBAC UserDingMapping methods** In `rbac.service.ts`: delete `getUserDingMappings()`, `createUserDingMapping()`, `deleteUserDingMapping()`, and the unbound-users query. In `rbac.controller.ts`: delete `GET /user-ding-mappings`, `POST /user-ding-mappings`, `DELETE /user-ding-mappings/:id`, `GET /user-ding-mappings/unbound-users`. In `rbac.dto.ts`: delete `CreateUserDingMappingDto`. - [ ] **Step 4: Verify compile** ```bash cd apps/server && npx tsc --noEmit ``` - [ ] **Step 5: Commit** ```bash git add apps/server/src/sync/ apps/server/src/rbac/ git commit -m "refactor: remove User-based import and RBAC UserDingMapping endpoints" ``` --- ### Task 5: Add class batch-import + extend class create **Files:** - Modify: `apps/server/src/classes/classes.service.ts` — new `batchImportStudents`, extend `create` - Modify: `apps/server/src/classes/classes.controller.ts` — new endpoint - Modify: `apps/server/src/classes/dto/class.dto.ts` — new DTOs - Modify: `apps/server/src/classes/classes.module.ts` — add StudentDingMapping **Interfaces:** - `POST /classes/:id/students/import` — body `{ dingUserIds: string[] }` → `{ imported: number, skipped: number }` - `POST /classes` — extended: optional `dingUserIds: string[]` - [ ] **Step 1: Add DTOs in `class.dto.ts`** ```typescript import { IsArray, IsString, ArrayNotEmpty, IsOptional } from 'class-validator'; export class BatchImportStudentsDto { @IsArray() @IsString({ each: true }) @ArrayNotEmpty() dingUserIds: string[]; } ``` In existing `CreateClassDto`, add: ```typescript @IsArray() @IsString({ each: true }) @IsOptional() dingUserIds?: string[]; ``` - [ ] **Step 2: Inject `StudentDingMapping` in classes.service.ts** ```typescript import { StudentDingMapping } from '../entities'; // ... @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, ``` - [ ] **Step 3: Add `batchImportStudents` method** ```typescript async batchImportStudents(classId: number, dingUserIds: string[]): Promise<{ imported: number; skipped: number }> { const classEntity = await this.classRepo.findOne({ where: { id: classId } }); if (!classEntity) throw new NotFoundException('班级不存在'); let imported = 0; let skipped = 0; for (const dingUserId of dingUserIds) { let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId } }); let studentId: number; if (mapping) { studentId = mapping.studentId; } else { const student = this.studentRepo.create({ name: `dd_${dingUserId}`, status: 'active', departmentId: classEntity.departmentId ?? undefined, }); const saved = await this.studentRepo.save(student); studentId = saved.id; mapping = this.studentDingMappingRepo.create({ dingUserId, studentId }); await this.studentDingMappingRepo.save(mapping); } const existing = await this.classStudentRepo.findOne({ where: { classId, studentId }, }); if (existing) { skipped++; continue; } await this.classStudentRepo.save( this.classStudentRepo.create({ classId, studentId, status: 'active', joinDate: new Date().toISOString().slice(0, 10), }), ); imported++; } return { imported, skipped }; } ``` - [ ] **Step 4: Extend `create` method** At end of `create`, before return: ```typescript if (dto.dingUserIds?.length) { await this.batchImportStudents(saved.id, dto.dingUserIds); } ``` - [ ] **Step 5: Add endpoint in classes.controller.ts** ```typescript import { BatchImportStudentsDto } from './dto/class.dto'; @Post(':id/students/import') @RequirePermission('class:edit') async batchImportStudents( @Param('id') id: string, @Body() dto: BatchImportStudentsDto, ) { return this.classesService.batchImportStudents(+id, dto.dingUserIds); } ``` - [ ] **Step 6: Add StudentDingMapping to classes.module.ts** ```typescript TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]), ``` - [ ] **Step 7: Verify compile + commit** ```bash cd apps/server && npx tsc --noEmit git add apps/server/src/classes/ git commit -m "feat: add batch-import students to class endpoint" ``` --- ### Task 6: Frontend — rewrite Drawer with checkable Tree + class list **Files:** - Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx` **Interfaces:** - Consumes: `GET /sync/dingtalk/org-tree-with-users`, `GET /classes`, `POST /classes/:id/students/import`, `POST /classes` - Produces: redesigned Drawer with left-right split layout - [ ] **Step 1: Update imports** ```typescript import { Tree, Row, Col, Card, List, Button, Space, Drawer, Alert, TreeSelect, message, Modal, Form, Input, DatePicker, InputNumber, Select, Tag } from 'antd'; import { SyncOutlined, BankOutlined, UserOutlined } from '@ant-design/icons'; ``` Remove: `ReloadOutlined` (no longer used), `Spin`, `Descriptions`, etc. - [ ] **Step 2: Replace state** ```typescript // Remove: teacherChecks, teacherRoles, defaultTeacherRoleId, roles, classMarks, classModalDept // Add: const [checkedKeys, setCheckedKeys] = useState([]); const [selectedClassId, setSelectedClassId] = useState(null); const [classes, setClasses] = useState([]); const [classForm] = Form.useForm(); const [classModalOpen, setClassModalOpen] = useState(false); const [importing, setImporting] = useState(false); ``` - [ ] **Step 3: Replace `buildTreeData` (checkable, no toggles)** ```typescript const buildTreeData = useCallback((nodes: any[]): any[] => { return nodes.map((node) => ({ title: ( {node.name} {node.users.length}人 ), key: `dept-${node.id}`, children: [ ...buildTreeData(node.children), ...node.users.map((u: any) => ({ title: {u.name}{u.mobile}, key: `user-${u.userid}`, })), ], })); }, []); ``` - [ ] **Step 4: Add `fetchClasses`** ```typescript const fetchClasses = async () => { try { const res = await api.get('/classes'); setClasses(Array.isArray(res) ? res : res.data ?? []); } catch { /* ignore */ } }; ``` Call `fetchClasses()` inside `handleFetchOrgTree`. - [ ] **Step 5: `handleJoinClass`** ```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: any) { message.error(e?.message || '导入失败'); } finally { setImporting(false); } }; ``` - [ ] **Step 6: `handleCreateClass` modal** ```typescript const handleCreateClass = async () => { try { const values = await classForm.validateFields(); const userIds = checkedKeys .filter((k) => String(k).startsWith('user-')) .map((k) => String(k).replace('user-', '')); const deptKeys = checkedKeys.filter((k) => String(k).startsWith('dept-')); const deptId = deptKeys.length > 0 ? Number(String(deptKeys[0]).replace('dept-', '')) : undefined; setImporting(true); await api.post('/classes', { ...values, dingUserIds: userIds, departmentId: deptId }); message.success('班级创建成功'); setClassModalOpen(false); classForm.resetFields(); setCheckedKeys([]); fetchClasses(); } catch (e: any) { message.error(e?.message || '创建失败'); } finally { setImporting(false); } }; ``` - [ ] **Step 7: Render Drawer with left-right layout** ```tsx { setDrawerOpen(false); }} width={900} footer={ } >
setCheckedKeys(checked as React.Key[])} />
setClassModalOpen(true)}>+ 创建班级}> ( setSelectedClassId(cls.id)} style={{ cursor: 'pointer', background: selectedClassId === cls.id ? '#e6f4ff' : undefined, borderRadius: 4, padding: '8px 12px', }} > )} />
{/* 创建班级 Modal */} { setClassModalOpen(false); classForm.resetFields(); }} confirmLoading={importing} destroyOnClose >