docs: remove 班主任 distinction — all teachers use roleType='teacher'
This commit is contained in:
@@ -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<string, number[]>();
|
||||||
|
|
||||||
|
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<Class>,
|
||||||
|
@InjectRepository(ClassStudent)
|
||||||
|
private readonly classStudentRepo: Repository<ClassStudent>,
|
||||||
|
```
|
||||||
|
|
||||||
|
更新 `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<number, number>(); // 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<Record<number, ClassMarkForm>>({});
|
||||||
|
const [classModalOpen, setClassModalOpen] = useState(false);
|
||||||
|
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const [classForm] = Form.useForm<ClassMarkForm>();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
<Modal
|
||||||
|
title={classMarks[classModalDept?.id ?? -1] ? '修改班级信息' : '标记为班级'}
|
||||||
|
open={classModalOpen}
|
||||||
|
onOk={handleClassModalOk}
|
||||||
|
onCancel={handleClassModalCancel}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={classForm} layout="vertical">
|
||||||
|
<Form.Item name="deptId" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="name" label="班级名称" rules={[{ required: true, message: '请输入班级名称' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="code" label="班级编码" rules={[{ required: true, message: '请输入班级编码' }]}>
|
||||||
|
<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="备注">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
需要新增 import:`Modal, DatePicker, InputNumber` from antd(已有 Modal、Drawer、Select、Input,检查是否缺少)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 树节点中嵌入班级操作**
|
||||||
|
|
||||||
|
在 `buildTreeData` 函数(约第 251 行)中,修改部门节点的 `title` 显示:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
title: (
|
||||||
|
<Space size="small">
|
||||||
|
<span>{node.name}</span>
|
||||||
|
{classMarks[node.id] ? (
|
||||||
|
<Tag
|
||||||
|
color="blue"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => openClassModal(node.id, node.name)}
|
||||||
|
>
|
||||||
|
班级: {classMarks[node.id].name} [已标记]
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<span>🏫</span>}
|
||||||
|
onClick={() => openClassModal(node.id, node.name)}
|
||||||
|
>
|
||||||
|
标为班级
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
key: `dept-${node.id}`,
|
||||||
|
children: [
|
||||||
|
...buildTreeData(node.children),
|
||||||
|
...node.users.map((u) => ({
|
||||||
|
title: (
|
||||||
|
<UserTreeNode
|
||||||
|
key={u.userid}
|
||||||
|
u={{ userid: u.userid, name: u.name, mobile: u.mobile }}
|
||||||
|
isTeacher={!!teacherChecks[u.userid]}
|
||||||
|
onToggle={() => { /* ... existing toggle logic ... */ }}
|
||||||
|
roleId={teacherRoles[u.userid]}
|
||||||
|
defaultRoleId={defaultTeacherRoleId}
|
||||||
|
roles={roles}
|
||||||
|
onRoleChange={(newRoleId) => { /* ... existing role change logic ... */ }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: `user-${u.userid}`,
|
||||||
|
isLeaf: true,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 修改 handleImportUsers payload**
|
||||||
|
|
||||||
|
编辑 `handleImportUsers`(约第 209 行),在 payload 中加上 classes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const payload = {
|
||||||
|
classes: Object.values(classMarks),
|
||||||
|
users: allUsers.map((u) => ({
|
||||||
|
dingUserId: u.userid,
|
||||||
|
name: u.name,
|
||||||
|
mobile: u.mobile,
|
||||||
|
roleId: teacherChecks[u.userid]
|
||||||
|
? (teacherRoles[u.userid] || defaultTeacherRoleId)
|
||||||
|
: null,
|
||||||
|
dingDeptIds: u.deptIds || [], // 新增
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`allUsers` 需要在 flatten 时也收集 deptIds。修改 flatten:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const flatten = (nodes: DingOrgTreeNodeExt[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
allUsers.push(...node.users);
|
||||||
|
flatten(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
由于 `node.users` 现在包含 `deptIds`,`allUsers` 的类型需调整。修改 `allUsers` 声明:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const allUsers: Array<{
|
||||||
|
dingUserId: string;
|
||||||
|
name: string;
|
||||||
|
mobile: string;
|
||||||
|
deptIds: number[];
|
||||||
|
}> = [];
|
||||||
|
```
|
||||||
|
|
||||||
|
在 flatten 中 push 时展开正确字段:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
allUsers.push(
|
||||||
|
...node.users.map((u) => ({
|
||||||
|
dingUserId: u.userid,
|
||||||
|
name: u.name,
|
||||||
|
mobile: u.mobile,
|
||||||
|
deptIds: u.deptIds || [],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
然后 payload 中 `u.deptIds` 可用。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 关闭抽屉时清理 classMarks**
|
||||||
|
|
||||||
|
在 `onClose` 处理中(已有 `setDrawerOpen(false)` 的地方)加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
setClassMarks({});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 编译前端验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/admin && npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
修正所有类型错误。
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||||
|
git commit -m "feat(admin): add class marking modal in DingTalk org import drawer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 后端 — 编写测试
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/server/src/sync/sync.service.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 改造后的 `importDingTalkUsers` (Task 2)
|
||||||
|
- Produces: 7 个新测试用例
|
||||||
|
|
||||||
|
**Skills to load before coding:**
|
||||||
|
- Tester agent — 但不在此处写测试,而是委托给 Tester 子代理
|
||||||
|
|
||||||
|
- [ ] **Step 1: 委托 Tester 子代理编写测试**
|
||||||
|
|
||||||
|
由于同步服务已有完善的 mock 基础设施,直接委托 Tester 代理根据 spec 编写以下用例:
|
||||||
|
|
||||||
|
1. **单部门标班级 + 1老师 + 1学生** — 验证 Class, ClassTeacher(roleType='teacher'), ClassStudent 均创建
|
||||||
|
2. **单部门多老师** — 所有老师均写入 ClassTeacher
|
||||||
|
3. **用户在多个被标记部门** — 同时加入多个班级的 ClassStudent 或 ClassTeacher
|
||||||
|
4. **班级编码重复** — 事务回滚,抛出 BadRequestException
|
||||||
|
5. **空部门(无人)** — 班级创建,返回 warning
|
||||||
|
6. **纯学生无老师** — 班级创建,ClassStudent 正确
|
||||||
|
7. **无 classes 参数** — 向下兼容,返回结果中 classCount=0
|
||||||
|
|
||||||
|
代理需扩展 mock Manager 以支持 `Class.create/save/findOne/count` 和 `ClassStudent.create/save/count`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行全部 sync 测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/server && npx jest --testPathPattern='sync.service.spec' --no-coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 全部通过。
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/server/src/sync/sync.service.spec.ts
|
||||||
|
git commit -m "test(sync): add class-marking import test cases"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 端到端验证 & 清理
|
||||||
|
|
||||||
|
**Files:** 无新增,仅验证
|
||||||
|
|
||||||
|
- [ ] **Step 1: 启动后端**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/server && npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
确认无启动错误。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 启动前端**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/admin && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 手动验证流程**
|
||||||
|
|
||||||
|
1. 打开浏览器 → 钉钉集成配置页
|
||||||
|
2. 点击「获取组织架构」→ 确认部门节点显示「🏫 标为班级」按钮
|
||||||
|
3. 点击按钮 → Modal 弹出,部门名已预填
|
||||||
|
4. 填写编码、班型 → 确定 → 节点显示 `🏫 班级: XXX [已标记]`
|
||||||
|
5. 在该部门下勾选一个用户为「老师」角色
|
||||||
|
6. 点击「导入」→ 确认成功
|
||||||
|
7. 到班级管理页验证:班级存在、老师关联正确、学生归属正确
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证向下兼容**
|
||||||
|
|
||||||
|
不标记任何班级,仅勾选老师学生 → 导入 → 确认行为不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
1. **Spec coverage**: DTO 扩展 ✓, 服务层逻辑 ✓, 前端树节点 ✓, Modal 表单 ✓, 导入 payload ✓, 错误场景 ✓, 测试策略 ✓
|
||||||
|
2. **Placeholder scan**: 无 TBD/TODO,所有代码块均为具体实现
|
||||||
|
3. **Type consistency**: `ImportUserDto.dingDeptIds` 在 Task 1 DTO 和 Task 2 service 中类型一致;`ClassMarkForm` 在 Task 3 定义和使用一致
|
||||||
@@ -130,11 +130,9 @@ export class ImportUsersDto {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 班主任判定
|
### 3. 教师角色判定
|
||||||
|
|
||||||
通过角色表查询:查找用户分配的角色中 `code === 'head_teacher'` 或 `name === '班主任'` 的角色。若命中且其 `dingDeptIds` 中存在被标记为班级的部门,则将该用户作为该班级的班主任。
|
通过角色表查询:导入时勾选的用户分配指定角色后即为「老师」。所有老师在班级关联中统一写入 `ClassTeacher`(`roleType = 'teacher'`),不区分班主任/任课老师。班级对老师为多对多关系。
|
||||||
|
|
||||||
「班主任」角色 code 尚未定义 → 在角色 seed 中新增 `head_teacher` 作为班主任角色的 code 标识。
|
|
||||||
|
|
||||||
### 4. 错误场景
|
### 4. 错误场景
|
||||||
|
|
||||||
@@ -142,7 +140,6 @@ export class ImportUsersDto {
|
|||||||
|---|---|
|
|---|---|
|
||||||
|班级编码重复|事务回滚,返回 `"班级编码 XX 已存在"`|
|
|班级编码重复|事务回滚,返回 `"班级编码 XX 已存在"`|
|
||||||
|部门标为班级但无人|班级正常创建(空班),warning|
|
|部门标为班级但无人|班级正常创建(空班),warning|
|
||||||
|一个班级多班主任|第一个设 headTeacherId,其余写入 ClassTeacher,warning|
|
|
||||||
|用户在多个被标记的部门|同时加入所有匹配班级|
|
|用户在多个被标记的部门|同时加入所有匹配班级|
|
||||||
|无 classes 参数|兼容旧调用,仅导入师生,不创建班级|
|
|无 classes 参数|兼容旧调用,仅导入师生,不创建班级|
|
||||||
|角色不存在|事务回滚,返回具体错误|
|
|角色不存在|事务回滚,返回具体错误|
|
||||||
|
|||||||
Reference in New Issue
Block a user