20 KiB
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 <Tree checkable> 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:
StudentDingMappingentity withid,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
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
cd apps/server && npx tsc --noEmit
Expected: zero new errors (pre-existing spec-file errors ignored).
- Step 7: Commit
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—syncAllandsyncOneUser
Interfaces:
-
Consumes:
StudentDingMappingentity (Task 1) -
Produces: same
{ deptCount, userCount }return type -
Step 1: Rewrite
syncOneUser
Replace existing syncOneUser (lines ~553-624) with:
private async syncOneUser(du: {
userid: string; name: string; mobile: string;
}): Promise<void> {
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
cd apps/server && npx tsc --noEmit
- Step 3: Commit
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+ removegetDeptDepthMap -
Step 1: Remove
getDeptDepthMapmethod (the ~37 lines added in commit0509175) -
Step 2: Restore
fetchOrgTreeWithUsersloop
Remove depthMap/maxDepth/filteredIds. Restore:
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
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— removeimportDingTalkUsers,ImportUserDto -
Modify:
apps/server/src/sync/sync.controller.ts— removePOST /dingtalk/import-users -
Modify:
apps/server/src/sync/dto/import-users.dto.ts— removeImportUsersDto -
Modify:
apps/server/src/rbac/rbac.service.ts— removegetUserDingMappings,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— removeCreateUserDingMappingDto -
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
cd apps/server && npx tsc --noEmit
- Step 5: Commit
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— newbatchImportStudents, extendcreate - 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: optionaldingUserIds: string[] -
Step 1: Add DTOs in
class.dto.ts
import { IsArray, IsString, ArrayNotEmpty, IsOptional } from 'class-validator';
export class BatchImportStudentsDto {
@IsArray()
@IsString({ each: true })
@ArrayNotEmpty()
dingUserIds: string[];
}
In existing CreateClassDto, add:
@IsArray()
@IsString({ each: true })
@IsOptional()
dingUserIds?: string[];
- Step 2: Inject
StudentDingMappingin classes.service.ts
import { StudentDingMapping } from '../entities';
// ...
@InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
- Step 3: Add
batchImportStudentsmethod
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
createmethod
At end of create, before return:
if (dto.dingUserIds?.length) {
await this.batchImportStudents(saved.id, dto.dingUserIds);
}
- Step 5: Add endpoint in classes.controller.ts
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
TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]),
- Step 7: Verify compile + commit
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
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
// Remove: teacherChecks, teacherRoles, defaultTeacherRoleId, roles, classMarks, classModalDept
// Add:
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<any[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
- Step 3: Replace
buildTreeData(checkable, no toggles)
const buildTreeData = useCallback((nodes: any[]): any[] => {
return nodes.map((node) => ({
title: (
<Space size="small">
<BankOutlined />
<span>{node.name}</span>
<Tag>{node.users.length}人</Tag>
</Space>
),
key: `dept-${node.id}`,
children: [
...buildTreeData(node.children),
...node.users.map((u: any) => ({
title: <Space><UserOutlined /><span>{u.name}</span><Tag>{u.mobile}</Tag></Space>,
key: `user-${u.userid}`,
})),
],
}));
}, []);
- Step 4: Add
fetchClasses
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
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:
handleCreateClassmodal
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
<Drawer
title="钉钉组织架构 — 批量导入"
open={drawerOpen}
onClose={() => { setDrawerOpen(false); }}
width={900}
footer={
<Space>
<Button onClick={() => { setDrawerOpen(false); }}>取消</Button>
<Button
type="primary"
loading={importing}
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 || selectedClassId === null}
onClick={handleJoinClass}
>加入选中的班级</Button>
<Button
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
onClick={() => setClassModalOpen(true)}
>创建班级</Button>
</Space>
}
>
<Row gutter={16}>
<Col span={14}>
<div style={{ maxHeight: '60vh', overflow: 'auto' }}>
<Tree
checkable
treeData={treeData}
defaultExpandAll
showLine={{ showLeafIcon: false }}
checkedKeys={checkedKeys}
onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
/>
</div>
</Col>
<Col span={10}>
<Card title="班级列表" size="small"
extra={<Button size="small" onClick={() => setClassModalOpen(true)}>+ 创建班级</Button>}>
<List
dataSource={classes}
renderItem={(cls: any) => (
<List.Item
onClick={() => setSelectedClassId(cls.id)}
style={{
cursor: 'pointer',
background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
borderRadius: 4,
padding: '8px 12px',
}}
>
<List.Item.Meta title={cls.name} description={`${cls.code} ${cls.classType || ''}`} />
</List.Item>
)}
/>
</Card>
</Col>
</Row>
{/* 创建班级 Modal */}
<Modal
title="创建班级"
open={classModalOpen}
onOk={handleCreateClass}
onCancel={() => { setClassModalOpen(false); classForm.resetFields(); }}
confirmLoading={importing}
destroyOnClose
>
<Form form={classForm} layout="vertical">
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
<Input placeholder="如 CS2024-01" />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
<Select options={[
{ value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' },
{ value: 'bootcamp', label: '集训营' },
{ value: 'sprint', label: '冲刺班' },
]} />
</Form.Item>
<Form.Item name="startDate" label="开班日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxStudents" label="最大人数">
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</Drawer>
- Step 8: Delete old code
Remove: UserTreeNode component, teacherChecks/teacherRoles/classMarks/classModalDept state, handleToggleTeacher/handleClassModalOk/handleClassModalCancel/handleImportUsers callbacks, buildTreeData (old version), per-department class mark modal, role fetch logic.
- Step 9: Verify compile
cd apps/admin && npx tsc --noEmit
- Step 10: Commit
git add apps/admin/src/pages/IntegrationConfig/index.tsx
git commit -m "feat: rewrite sync drawer with checkable tree + class batch import"
Task 7: Cleanup tests
Files:
-
Modify:
apps/server/src/sync/sync.service.spec.ts— swap UserDingMapping → StudentDingMapping, remove User-related mocks -
Modify:
apps/server/src/attendance/attendance.service.spec.ts— same swap -
Step 1: Fix sync.service.spec.ts
Replace all UserDingMapping with StudentDingMapping. Remove userRepo, roleRepo, bcrypt mocks. Update test cases that tested importDingTalkUsers — those tests are removed (the method is gone in Task 4). Keep only remaining tests.
- Step 2: Fix attendance.service.spec.ts
Replace UserDingMapping with StudentDingMapping in imports and mock providers.
- Step 3: Verify compile
cd apps/server && npx tsc --noEmit
cd apps/admin && npx tsc --noEmit
- Step 4: Commit
git add apps/server/src/sync/sync.service.spec.ts apps/server/src/attendance/attendance.service.spec.ts
git commit -m "chore: update tests for StudentDingMapping"