forked from wangziqi/gongxue-base
docs: student ding mapping + batch operations implementation plan
This commit is contained in:
636
docs/superpowers/plans/2026-07-09-student-ding-mapping.md
Normal file
636
docs/superpowers/plans/2026-07-09-student-ding-mapping.md
Normal file
@@ -0,0 +1,636 @@
|
||||
# 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: `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<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**
|
||||
|
||||
```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<StudentDingMapping>,
|
||||
```
|
||||
|
||||
- [ ] **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<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)**
|
||||
|
||||
```typescript
|
||||
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`**
|
||||
|
||||
```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
|
||||
<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**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
Reference in New Issue
Block a user