- 房间号:{drawerRoom.roomNumber}
- 楼栋:{drawerRoom.building || '-'}
- 楼层:{drawerRoom.floor ?? '-'}
- 类型:{drawerRoom.roomType || '-'}
- 额定人数:{drawerRoom.capacity}
- 租赁类别:{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
- 月租金:{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
- 状态:{statusMap[drawerRoom.status]?.text}
-
- ),
- },
- {
- key: 'beds',
- label: `床位管理 (${beds.length})`,
- children: (
-
-
-
}
- disabled={drawerRoom?.status === 'archived'}
- onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
- >
- 添加床位
-
-
- }
- onConfirm={() => {
- const input = document.getElementById('batch-bed-count') as HTMLInputElement;
- handleBatchBeds(input ? parseInt(input.value) || 4 : 4);
- }}
- okText="生成"
- disabled={drawerRoom?.status === 'archived'}
- >
-
-
-
-
{
- const map: Record = {
- available: { text: '空闲', color: 'green' },
- occupied: { text: '占用', color: 'blue' },
- maintenance: { text: '维修', color: 'orange' },
- };
- return {map[s]?.text || s};
- },
- },
- { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
- {
- title: '操作', width: 120,
- render: (_: any, r: any) => (
-
- { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }}
- >
- 编辑
-
- {r.status !== 'occupied' && (
- handleDeleteBed(r.id)}>
-
- 删除
-
-
- )}
-
- ),
- },
- ]}
- />
-
- ),
- },
- {
- key: 'lockers',
- label: `柜子管理 (${lockers.length})`,
- children: (
-
-
-
}
- disabled={drawerRoom?.status === 'archived'}
- onClick={() => { setLockerEditing(null); lockerForm.resetFields(); setLockerModalOpen(true); }}
- >
- 添加柜子
-
-
- }
- onConfirm={() => {
- const input = document.getElementById('batch-locker-count') as HTMLInputElement;
- handleBatchLockers(input ? parseInt(input.value) || 4 : 4);
- }}
- okText="生成"
- disabled={drawerRoom?.status === 'archived'}
- >
-
-
-
-
{
- const map: Record = {
- available: { text: '空闲', color: 'green' },
- occupied: { text: '占用', color: 'blue' },
- maintenance: { text: '维修', color: 'orange' },
- };
- return {map[s]?.text || s};
- },
- },
- { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
- {
- title: '操作', width: 120,
- render: (_: any, r: any) => (
-
- { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }}
- >
- 编辑
-
- {r.status !== 'occupied' && (
- handleDeleteLocker(r.id)}>
-
- 删除
-
-
- )}
-
- ),
- },
- ]}
- />
-
- ),
- },
- ]}
- />
-
-```
-
-- [ ] **Step 7: 修改"查看住户"按钮行为**
-
-将 `showDetail(record.id)` 的 onClick 改为:
-
-```typescript
-onClick={async () => {
- setDrawerRoom(record);
- setDrawerOpen(true);
- // 异步加载床位和柜子
- await Promise.all([fetchBeds(record.id), fetchLockers(record.id)]);
-}}
-```
-
-- [ ] **Step 8: 新增床位/柜子编辑 Modal**
-
-在 Drawer 之外(放在现有"添加宿舍"Modal 之后、最终 `` 之前),新增两个小型 Modal:
-
-```typescript
- {/* 床位编辑弹窗 */}
- { setBedModalOpen(false); setBedEditing(null); }}
- confirmLoading={savingBed}
- okText="保存"
- >
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 柜子编辑弹窗 */}
- { setLockerModalOpen(false); setLockerEditing(null); }}
- confirmLoading={savingLocker}
- okText="保存"
- >
-
-
-
-
-
-
-
-
-
-
-
-```
-
-- [ ] **Step 9: 可删除旧的 simple detail Modal**
-
-移除第 481-502 行的旧 Modal(如果上一步已删除则跳过)。
-
-- [ ] **Step 10: Commit**
-
-```bash
-git add apps/admin/src/pages/Rooms/index.tsx
-git commit -m "feat: upgrade Room detail to Drawer with Bed/Locker tabs"
-```
-
----
-
-### Task 8: Occupancies 页面 — 入住登记改造
-
-**Files:**
-- Modify: `apps/admin/src/pages/Occupancies/index.tsx`
-
-**Consumes:** `/rooms/:id/beds/available`, `/rooms/:id/lockers/available` API
-**Produces:** 入住表单新增床位/柜子选择,表格新增床位号/柜子号列
-
-- [ ] **Step 1: 新增状态变量**
-
-在组件 state 区新增:
-
-```typescript
- const [availableBeds, setAvailableBeds] = useState([]);
- const [availableLockers, setAvailableLockers] = useState([]);
-```
-
-- [ ] **Step 2: 新增房间选择时的床位加载**
-
-```typescript
- const handleRoomChange = async (roomId: number) => {
- checkInForm.setFieldValue('bedId', undefined);
- checkInForm.setFieldValue('lockerId', undefined);
- if (!roomId) {
- setAvailableBeds([]);
- setAvailableLockers([]);
- return;
- }
- try {
- const [beds, lockers] = await Promise.all([
- api.get(`/rooms/${roomId}/beds/available`),
- api.get(`/rooms/${roomId}/lockers/available`),
- ]);
- setAvailableBeds(beds);
- setAvailableLockers(lockers);
- // 如果仅有一张可用床位,自动选中
- if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id);
- } catch (e) { console.error(e); }
- };
-```
-
-- [ ] **Step 3: 修改入住表单 — 在租赁方字段后新增床位/柜子**
-
-在现有入住登记 Modal 的 Form 中,`tenantId` Form.Item 之后插入:
-
-```typescript
-
-
- {availableBeds.length > 0 && (
-
- 空闲 {availableBeds.length} 张床位
-
- )}
-
-
-```
-
-- [ ] **Step 4: 修改房间选择触发加载**
-
-找到入住表单中房间的 `Form.Item`,给 Select 加 `onChange`:
-
-```typescript
-
-
-```
-
-- [ ] **Step 5: 入住提交时传 bedId/lockerId**
-
-确认 `handleCheckIn` 中 payload 包含:
-
-```typescript
- bedId: values.bedId,
- lockerId: values.lockerId || undefined,
-```
-
-- [ ] **Step 6: 表格新增床位号/柜子号列**
-
-在表格 columns 中,`宿舍` 列之后新增:
-
-```typescript
- {
- title: '床位', width: 80,
- render: (_: any, r: any) => r.bed?.bedNumber || '-',
- },
- {
- title: '柜子', width: 80,
- render: (_: any, r: any) => r.locker?.lockerNumber || '-',
- },
-```
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add apps/admin/src/pages/Occupancies/index.tsx
-git commit -m "feat: add bed/locker selection to check-in form and occupancy table"
-```
-
----
-
-### Task 9: RoomVisual 卡片 — 床位统计
-
-**Files:**
-- Modify: `apps/admin/src/pages/RoomVisual/index.tsx`
-
-**Consumes:** bed counts (可从现有 data 中扩展,或新增 API)
-**Produces:** 卡片底部显示「🛏 2/4 床」
-
-- [ ] **Step 1: 确认 API 返回 bed 数据**
-
-检查 `/rooms/visual` 返回结构是否包含床位统计。若不包含,先修改 `getRoomVisual` 方法在 `rooms.service.ts` 中添加 bed 统计:
-
-```typescript
-// 在 getRoomVisual 方法中,为每个 room 计算 bed 统计:
-const totalBeds = await this.bedRepo.count({ where: { roomId: room.id } });
-const occupiedBeds = await this.bedRepo.count({ where: { roomId: room.id, status: 'occupied' } });
-// 添加字段:totalBeds, occupiedBeds
-```
-
-- [ ] **Step 2: 在 RoomVisual 卡片底部新增床位统计**
-
-在卡片 JSX 中,`getTenantTags` 之后、`getStatusLabel` 之前的位置,新增:
-
-```typescript
-{/* 床位统计 */}
-{room.totalBeds > 0 && (
- = room.totalBeds ? '#ff4d4f' : '#52c41a', marginBottom: 4 }}>
- 床位: {room.occupiedBeds}/{room.totalBeds}
-
-)}
-```
-
-- [ ] **Step 3: 修改统计栏**
-
-将现有的 `availableBeds`(基于 capacity - currentCount)替换为基于实际 beds 统计:
-
-```typescript
- const totalBeds = rooms.reduce((sum: number, r: any) => sum + (r.totalBeds || 0), 0);
- const occupiedBeds = rooms.reduce((sum: number, r: any) => sum + (r.occupiedBeds || 0), 0);
- const availableBedsCount = totalBeds - occupiedBeds;
-```
-
-并在 Statistic 卡片中使用真实统计值。
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add apps/admin/src/pages/RoomVisual/index.tsx apps/server/src/rooms/rooms.service.ts
-git commit -m "feat: add bed occupancy stats to RoomVisual cards"
-```
-
----
-
-### Task 10: 端到端验证 & 清理
-
-**Files:** 无新建,仅验证
-**Consumes:** 所有前序任务
-**Produces:** 验证通过的完整功能
-
-- [ ] **Step 1: 启动后端**
-
-```bash
-cd apps/server && npm run start:dev &
-```
-
-Wait for: Nest application successfully started.
-
-- [ ] **Step 2: 测试床位 API**
-
-```bash
-# 获取某房间的床位
-curl -s http://localhost:3000/api/rooms/1/beds | head -c 200
-# Expect: JSON 数组,包含 bedNumber, status 字段
-```
-
-- [ ] **Step 3: 测试入住 API(带床位)**
-
-```bash
-curl -s -X POST http://localhost:3000/api/occupancies/check-in \
- -H "Content-Type: application/json" \
- -d '{"studentId":1,"roomId":1,"checkInDate":"2026-07-09","bedId":1}' | head -c 200
-# Expect: 返回入住记录,包含 bedId
-```
-
-- [ ] **Step 4: 启动前端验证**
-
-```bash
-cd apps/admin && npm run dev
-```
-
-打开浏览器,验证:
-1. 宿舍管理 → 点击"查看住户" → 弹出 Drawer → 三个 Tab 正常切换
-2. 床位管理 Tab:可添加/编辑/删除/批量生成床位
-3. 柜子管理 Tab:同样 CRUD 操作正常
-4. 入住管理 → 入住登记 → 选房间后自动加载可用床位下拉
-5. 入住后床位状态变为"占用"
-6. 退宿后床位状态恢复"空闲"
-7. 宿舍总览卡片显示床位统计
-
-- [ ] **Step 5: 修复发现的问题并提交**
-
-```bash
-git add -A
-git commit -m "chore: E2E verification fixes for bed/locker management"
-```
-
----
-
-## Self-Review
-
-- [x] **Spec coverage**: All 8 sections covered — data model (Task 1-2), backend API (Task 3-5), occupancy integration (Task 6), frontend Drawer (Task 7), check-in form (Task 8), RoomVisual (Task 9), migration (Task 2)
-- [x] **Placeholder scan**: No TBD/TODO. All code blocks are concrete.
-- [x] **Type consistency**: `Bed`, `Locker` entity names match across tasks. `bedId`/`lockerId` field names consistent. `CreateBedDto`/`UpdateBedDto` used in both service and controller.
-- [x] **Route ordering**: Called out the critical `:roomId` vs `:id` route ordering issue in Task 5.
diff --git a/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md b/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md
deleted file mode 100644
index c6fcadb..0000000
--- a/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md
+++ /dev/null
@@ -1,805 +0,0 @@
-# 钉钉导入标记班级 — 实现计划
-
-> **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();
-
-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,
-@InjectRepository(ClassStudent)
-private readonly classStudentRepo: Repository,
-```
-
-更新 `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(); // 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>({});
-const [classModalOpen, setClassModalOpen] = useState(false);
-const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
-const [classForm] = Form.useForm();
-```
-
-- [ ] **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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-需要新增 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: (
-
- {node.name}
- {classMarks[node.id] ? (
- openClassModal(node.id, node.name)}
- >
- 班级: {classMarks[node.id].name} [已标记]
-
- ) : (
-
- )}
-
- ),
- key: `dept-${node.id}`,
- children: [
- ...buildTreeData(node.children),
- ...node.users.map((u) => ({
- title: (
- { /* ... 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 定义和使用一致
diff --git a/docs/superpowers/plans/2026-07-09-dingtalk-import-fixes.md b/docs/superpowers/plans/2026-07-09-dingtalk-import-fixes.md
deleted file mode 100644
index a838060..0000000
--- a/docs/superpowers/plans/2026-07-09-dingtalk-import-fixes.md
+++ /dev/null
@@ -1,324 +0,0 @@
-# DingTalk 导入链路修复 — 实现计划
-
-> **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:** 修复钉钉导入链路的两个阻塞 bug:前端叶子节点判断、后端 CampusScope 过滤导致学生/考勤数据不可见。
-
-**Architecture:** 教学域(学生、考勤)移除 CampusScope 部门过滤,改为依赖 RBAC 权限码控制访问;管理域(宿舍、财务)保留 CampusScope 不变。
-
-**Tech Stack:** React 19 + TypeScript + NestJS 11 + TypeORM
-
-## Global Constraints
-
-- 遵循现有 NestJS 模块结构
-- 后端编译检查:`npx tsc --noEmit -p tsconfig.build.json`
-- 前端编译检查:`npx tsc --noEmit`
-- 不改动 `syncAll`、`importDingTalkUsers`、RBAC 权限码
-- 不改动 `OccupanciesService`、`RoomsService`、`BillsService`、`ExpensesService`(保留 CampusScope)
-
----
-
-### Task 1: 前端 — 叶子节点判断
-
-**Files:**
-- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx:375-393`
-
-**Interfaces:**
-- Consumes: `DingOrgTreeNodeExt` (has `children: DingOrgTreeNodeExt[]`, `users: Array<...>`)
-- Produces: `DataNode[]` tree nodes with conditional class-mark button
-
-- [ ] **Step 1: Read current code to confirm line numbers**
-
-Run: `read apps/admin/src/pages/IntegrationConfig/index.tsx:373-395`
-
-- [ ] **Step 2: Modify buildTreeData — add leaf-node condition**
-
-当前 `node.children` 和 `node.users` 渲染逻辑不需要改,只需要在 `title` 渲染部分(第 373–395 行)用条件包裹"标为班级"按钮:
-
-```tsx
-const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
- return nodes.map((node) => ({
- title: (
-
- {node.name}
- {node.children.length === 0 && node.users.length > 0 && (
- classMarks[node.id] ? (
- openClassModal(node.id, node.name)}
- >
- 班级: {classMarks[node.id].name} [已标记]
-
- ) : (
-
- )
- )}
-
- ),
- key: `dept-${node.id}`,
- children: [
- ...buildTreeData(node.children),
- ...node.users.map((u) => ({ ... })),
- ],
- }));
-}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
-```
-
-改动要点:用 `{node.children.length === 0 && node.users.length > 0 && ( ... )}` 包裹原来的三元表达式。只有无子部门且有用户的叶子节点才显示按钮。
-
-- [ ] **Step 3: 验证 TypeScript 编译**
-
-Run: `cd /Users/tiku1/code/gongxue-base/apps/admin && npx tsc --noEmit`
-Expected: no errors
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add apps/admin/src/pages/IntegrationConfig/index.tsx
-git commit -m "fix(admin): only show class-mark button on leaf departments in DingTalk org import"
-```
-
----
-
-### Task 2: 后端 — StudentsService 移除 CampusScope
-
-**Files:**
-- Modify: `apps/server/src/students/students.service.ts`
-
-**Interfaces:**
-- Consumes: (none external — internal change only)
-- Produces: `findAll(query)` returns `Promise` — same signature, no scope filtering
-
-- [ ] **Step 1: Read current code**
-
-Run: `read apps/server/src/students/students.service.ts`
-
-- [ ] **Step 2: Remove CampusScope import**
-
-Delete line 4:
-```typescript
-import { CampusScope } from '../common/campus-scope';
-```
-
-- [ ] **Step 3: Remove constructor parameter**
-
-SWAP lines 14-20 — remove `private readonly scope: CampusScope` and trailing comma on line 18:
-
-```typescript
- constructor(
- @InjectRepository(Student) private repo: Repository,
- @InjectRepository(ClassStudent) private classStudentRepo: Repository,
- @InjectRepository(Class) private classRepo: Repository,
- @InjectRepository(AttendanceRecord) private attendanceRepo: Repository,
- ) {}
-```
-
-- [ ] **Step 4: Remove scope.filter call in findAll**
-
-SWAP lines 22-33 — remove the `filteredWhere` intermediate. `findAll` now uses `where` directly:
-
-```typescript
- async findAll(query?: { name?: string; status?: string; includeArchived?: boolean; tenantId?: number | string }) {
- const where: FindOptionsWhere = {};
- if (query?.name) where.name = Like(`%${query.name}%`);
- if (query?.tenantId) where.tenantId = Number(query.tenantId);
- if (query?.status) {
- where.status = query.status;
- } else if (!query?.includeArchived) {
- where.status = Not(In(['archived']));
- }
- return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
- }
-```
-
-- [ ] **Step 5: Verify TypeScript compile**
-
-Run: `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json`
-Expected: no errors (ignore pre-existing errors in files not touched)
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add apps/server/src/students/students.service.ts
-git commit -m "fix(server): remove CampusScope from StudentsService, classes are teacher-managed not dept-filtered"
-```
-
----
-
-### Task 3: 后端 — AttendanceService 移除 CampusScope
-
-**Files:**
-- Modify: `apps/server/src/attendance/attendance.service.ts`
-
-**Interfaces:**
-- Consumes: (none external — internal change only)
-- Produces: all public method signatures unchanged, no scope filtering applied
-
-- [ ] **Step 1: Read current code to confirm line numbers**
-
-Run: `read apps/server/src/attendance/attendance.service.ts:1-40`
-
-- [ ] **Step 2: Remove CampusScope import (line 9)**
-
-```typescript
-import { CampusScope } from '../common/campus-scope';
-```
-→ Delete this line.
-
-- [ ] **Step 3: Remove constructor parameter (line 39)**
-
-SWAP the constructor body — remove `private readonly scope: CampusScope`:
-
-```typescript
- constructor(
- @InjectRepository(AttendanceRecord)
- private attendanceRepo: Repository,
- @InjectRepository(DingAttendanceRaw)
- private dingRawRepo: Repository,
- @InjectRepository(Class)
- private classRepo: Repository,
- @InjectRepository(Student)
- private studentRepo: Repository,
- @InjectRepository(ClassSchedule)
- private scheduleRepo: Repository,
- @InjectRepository(ClassStudent)
- private classStudentRepo: Repository,
- @InjectRepository(UserDingMapping)
- private userDingMappingRepo: Repository,
- ) {}
-```
-
-- [ ] **Step 4: Remove scope block in getSummary (lines 182-185)**
-
-Delete lines 182-185:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds) {
- qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
- }
-```
-
-- [ ] **Step 5: Remove scope block in findAll (lines 287-290)**
-
-Delete lines 287-290:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds) {
- qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
- }
-```
-
-- [ ] **Step 6: Remove scope blocks in getClasses (lines 327-330 and 339)**
-
-Delete lines 327-330:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds) {
- qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
- }
-```
-
-SWAP line 339 — replace `await this.scope.filter({ id: In(classIds) })` with `{ id: In(classIds) }`:
-```typescript
- const classes = await this.classRepo.find({ where: { id: In(classIds) } });
-```
-
-- [ ] **Step 7: Remove scope block in findAllForExport (lines 422-425)**
-
-Delete lines 422-425:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds) {
- qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
- }
-```
-
-- [ ] **Step 8: Remove scope guard in update (lines 460-463)**
-
-Delete lines 460-463:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds && !scopeIds.includes(record.departmentId)) {
- throw new NotFoundException(`AttendanceRecord ${id} not found`);
- }
-```
-
-- [ ] **Step 9: Remove scope guard in remove (lines 482-485)**
-
-Delete lines 482-485:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds && !scopeIds.includes(record.departmentId)) {
- throw new NotFoundException(`AttendanceRecord ${id} not found`);
- }
-```
-
-- [ ] **Step 10: Remove scope block in getReport (lines 494-497)**
-
-Delete lines 494-497:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds) {
- qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
- }
-```
-
-- [ ] **Step 11: Remove scope block in getAlerts (lines 570-573)**
-
-Delete lines 570-573:
-```typescript
- const scopeIds = await this.scope.getScopeDepartmentIds();
- if (scopeIds) {
- qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
- }
-```
-
-- [ ] **Step 12: Verify TypeScript compile**
-
-Run: `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json`
-Expected: no errors (ignore pre-existing errors in files not touched)
-
-- [ ] **Step 13: Commit**
-
-```bash
-git add apps/server/src/attendance/attendance.service.ts
-git commit -m "fix(server): remove CampusScope from AttendanceService, attendance is teacher-managed not dept-filtered"
-```
-
----
-
-### Task 4: 端到端验证
-
-- [ ] **Step 1: 启动后端**
-
-```bash
-cd /Users/tiku1/code/gongxue-base/apps/server && npm run start:dev
-```
-
-- [ ] **Step 2: 启动前端**
-
-```bash
-cd /Users/tiku1/code/gongxue-base/apps/admin && npm run dev
-```
-
-- [ ] **Step 3: 验证场景**
-
-1. 以非超管老师身份登录,打开集成配置页面
-2. 获取钉钉组织架构 → 确认父部门没有"标为班级"按钮,叶子部门有
-3. 标记班级、勾选老师、执行导入 → 确认导入成功
-4. 导航到学生管理页面 → 确认能看到导入的学生
-5. 导航到考勤管理页面 → 确认有数据
-
-- [ ] **Step 4: Commit verification notes**
-
-```bash
-git add -A && git commit -m "chore: end-to-end verification notes"
-```
diff --git a/docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md b/docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md
deleted file mode 100644
index de60555..0000000
--- a/docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md
+++ /dev/null
@@ -1,876 +0,0 @@
-# DingTalk Sync Role Selection — 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.
-
-**Goal:** Move DingTalk user sync from Users page to IntegrationConfig page with a Drawer-based tree UI for selecting which synced users are teachers (with role assignment) vs students.
-
-**Architecture:** Backend adds two endpoints: one to fetch the DingTalk org tree with users attached, one to import users with role/student assignment. Frontend adds a "同步用户" Tab on IntegrationConfig page with a Drawer tree; removes the old sync button and mark-staff/mark-student buttons from Users page.
-
-**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Vite + Ant Design 6 (frontend)
-
-## Global Constraints
-
-- MUST inject superpowers:ui-ux-pro-max and superpowers:vercel-react-best-practices during implementation
-- Follow existing NestJS module patterns: entity/dto/service/controller
-- Frontend pages in `apps/admin/src/pages/` with independent directories
-- RBAC permission decorators on all new endpoints
-- Use existing `api` axios instance for frontend API calls
-- Default teacher role: "班主任" (look up by name from `/rbac/roles`)
-- `roleId: null` (not undefined) marks a user as student
-
----
-
-### Task 1: Backend — New type and fetchOrgTreeWithUsers
-
-**Files:**
-- Modify: `apps/server/src/integration/dingtalk.service.ts`
-
-**Interfaces:**
-- Produces: `DingOrgTreeNodeWithUsers` (exported interface), `fetchOrgTreeWithUsers(rootDeptId?: number): Promise`
-
-- [ ] **Step 1: Add DingOrgTreeNodeWithUsers type**
-
-After the existing `DingOrgTreeNode` interface (line ~73), add:
-
-```typescript
-/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
-export interface DingOrgTreeNodeWithUsers {
- id: number;
- name: string;
- parentId: number;
- children: DingOrgTreeNodeWithUsers[];
- users: Array<{ userid: string; name: string; mobile: string }>;
-}
-```
-
-- [ ] **Step 2: Add fetchOrgTreeWithUsers method**
-
-After `fetchOrgTree` method (line ~456), add:
-
-```typescript
- /**
- * 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。
- * 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。
- */
- async fetchOrgTreeWithUsers(rootDeptId = 1): Promise {
- if (!this.configured) {
- throw new ServiceUnavailableException('钉钉未配置');
- }
- const token = await this.getAccessToken();
- const deptIds = await this.getAllDeptIds(token, rootDeptId);
-
- // 拉每个部门详情
- const nodes: DingOrgTreeNodeWithUsers[] = [];
- for (let i = 0; i < deptIds.length; i++) {
- if (i > 0) await this.delay(i);
- const detail = await this.getDeptDetail(token, deptIds[i]);
- if (!detail) continue;
-
- // 拉该部门下的用户
- const dingUsers = await this.getDeptUsers(token, deptIds[i]);
-
- 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,
- })),
- });
- }
-
- // 全局去重:同一个 dingUserId 可能在多个部门出现
- const seenUserIds = new Set();
- for (const node of nodes) {
- node.users = node.users.filter((u) => {
- if (seenUserIds.has(u.userid)) return false;
- seenUserIds.add(u.userid);
- return true;
- });
- }
-
- // 组装成树
- const map = new Map();
- nodes.forEach((n) => map.set(n.id, n));
- const roots: DingOrgTreeNodeWithUsers[] = [];
- for (const node of nodes) {
- const parent = map.get(node.parentId);
- if (parent && node.id !== rootDeptId) {
- parent.children.push(node);
- } else {
- roots.push(node);
- }
- }
- return roots;
- }
-```
-
-- [ ] **Step 3: Build check**
-
-```bash
-cd apps/server && npx tsc --noEmit -p tsconfig.build.json
-```
-
-Expected: no new errors (existing pre-existing errors may remain).
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add apps/server/src/integration/dingtalk.service.ts
-git commit -m "feat: add fetchOrgTreeWithUsers to DingTalkService"
-```
-
----
-
-### Task 2: Backend — SyncService new methods
-
-**Files:**
-- Modify: `apps/server/src/sync/sync.service.ts`
-- Modify: `apps/server/src/sync/sync.module.ts`
-
-**Interfaces:**
-- Consumes: `DingOrgTreeNodeWithUsers` from Task 1
-- Produces: `getDingTalkOrgTreeWithUsers(rootDeptId?: number): Promise`, `importDingTalkUsers(users: ImportUserDto[]): Promise<{ teacherCount: number; studentCount: number; skipped: number }>`
-
-- [ ] **Step 1: Add ImportUserDto and inject new repos**
-
-In `sync.service.ts`, after existing imports, add:
-
-```typescript
-import { User } from '../entities/user.entity';
-import { Student } from '../entities/student.entity';
-import { Role } from '../entities/role.entity';
-import * as bcrypt from 'bcryptjs';
-```
-
-Add to constructor injection (after existing `mappingRepo`):
-
-```typescript
- @InjectRepository(User)
- private readonly userRepo: Repository,
- @InjectRepository(Student)
- private readonly studentRepo: Repository,
- @InjectRepository(Role)
- private readonly roleRepo: Repository,
-```
-
-Add DTO interface at top of file (before class):
-
-```typescript
-export interface ImportUserDto {
- dingUserId: string;
- name: string;
- mobile: string;
- roleId: number | null;
-}
-```
-
-- [ ] **Step 2: Add getDingTalkOrgTreeWithUsers method**
-
-After existing `getDingTalkOrgTree` method (line ~93), add:
-
-```typescript
- /** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */
- async getDingTalkOrgTreeWithUsers(rootDeptId = 1) {
- return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
- }
-```
-
-- [ ] **Step 3: Add importDingTalkUsers method**
-
-After the new `getDingTalkOrgTreeWithUsers`, add:
-
-```typescript
- /**
- * 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。
- * 已存在 UserDingMapping 的记录跳过。
- */
- async importDingTalkUsers(users: ImportUserDto[]): Promise<{
- teacherCount: number;
- studentCount: number;
- skipped: number;
- }> {
- let teacherCount = 0;
- let studentCount = 0;
- let skipped = 0;
-
- for (const u of users) {
- // 检查是否已存在映射
- const existing = await this.mappingRepo.findOne({
- where: { dingUserId: u.dingUserId },
- });
- if (existing) {
- skipped++;
- continue;
- }
-
- try {
- const username = u.mobile || `dd_${u.dingUserId}`;
- const passwordHash = await bcrypt.hash('123456', 10);
-
- const user = this.userRepo.create({
- username,
- name: u.name,
- passwordHash,
- isActive: true,
- });
- await this.userRepo.save(user);
-
- if (u.roleId != null) {
- // 老师:分配角色
- const role = await this.roleRepo.findOne({ where: { id: u.roleId } });
- if (role) {
- user.roles = [role];
- await this.userRepo.save(user);
- } else {
- this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 未分配角色`);
- }
- teacherCount++;
- } else {
- // 学生:创建 Student 记录
- const student = this.studentRepo.create({
- name: u.name,
- phone: u.mobile || undefined,
- userId: user.id,
- status: 'active',
- });
- await this.studentRepo.save(student);
- studentCount++;
- }
-
- // 创建映射
- const mapping = this.mappingRepo.create({
- dingUserId: u.dingUserId,
- userId: user.id,
- dingName: u.name,
- dingMobile: u.mobile,
- });
- await this.mappingRepo.save(mapping);
- } catch (err: unknown) {
- const msg = err instanceof Error ? err.message : String(err);
- this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`);
- }
- }
-
- this.logger.log(
- `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`,
- );
- return { teacherCount, studentCount, skipped };
- }
-```
-
-- [ ] **Step 4: Update sync.module.ts — add User, Student, Role entities**
-
-In `TypeOrmModule.forFeature([...])` array, add `User`, `Student`, `Role` to the imports. Also add the import for them at the top:
-
-```typescript
-import {
- SyncLog,
- SyncState,
- UserDingMapping,
- ClassSchedule,
- Department,
- UserDepartment,
- ClassTeacher,
- User,
- Student,
- Role,
-} from '../entities';
-```
-
-And in the `forFeature` array after `ClassTeacher`:
-
-```typescript
- User,
- Student,
- Role,
-```
-
-- [ ] **Step 5: Build check**
-
-```bash
-cd apps/server && npx tsc --noEmit -p tsconfig.build.json
-```
-
-Expected: no new errors from modified files.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.module.ts
-git commit -m "feat: add importDingTalkUsers and org-tree-with-users to SyncService"
-```
-
----
-
-### Task 3: Backend — SyncController new endpoints
-
-**Files:**
-- Modify: `apps/server/src/sync/sync.controller.ts`
-
-**Interfaces:**
-- Consumes: `getDingTalkOrgTreeWithUsers`, `importDingTalkUsers` from Task 2
-
-- [ ] **Step 1: Add org-tree-with-users endpoint**
-
-After `getDingTalkOrgTree` endpoint (line ~41), add:
-
-```typescript
- /** 获取钉钉组织部门树(含用户),供同步用户选择器使用 */
- @Get('dingtalk/org-tree-with-users')
- @RequirePermission('sync:read')
- async getDingTalkOrgTreeWithUsers(@Query('rootDeptId') rootDeptId?: string) {
- const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
- const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId);
- return { success: true, data: tree };
- }
-```
-
-- [ ] **Step 2: Add import-users endpoint**
-
-After the new endpoint above, add:
-
-```typescript
- /** 导入钉钉用户:老师分配角色,学生创建 Student */
- @Post('dingtalk/import-users')
- @RequirePermission('sync:trigger')
- async importDingTalkUsers(@Body() body: { users: Array<{ dingUserId: string; name: string; mobile: string; roleId: number | null }> }) {
- const result = await this.syncService.importDingTalkUsers(body.users);
- return { success: true, ...result };
- }
-```
-
-Add `Body` to the imports from `@nestjs/common` at the top if not already present (check line 1 — `BadRequestException` is there, add `Body`):
-
-```typescript
-import { BadRequestException, Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
-```
-
-- [ ] **Step 3: Build check**
-
-```bash
-cd apps/server && npx tsc --noEmit -p tsconfig.build.json
-```
-
-Expected: no new errors.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add apps/server/src/sync/sync.controller.ts
-git commit -m "feat: add org-tree-with-users and import-users endpoints"
-```
-
----
-
-### Task 4: Frontend — Users page cleanup
-
-**Files:**
-- Modify: `apps/admin/src/pages/Users/index.tsx`
-
-- [ ] **Step 1: Remove imports**
-
-Remove from import line (line 1): `useCallback` (if only used by sync), `CloudDownloadOutlined` (line 15), `TreeSelect` (from antd imports).
-
-Check: `useCallback` is also used by `fetchData` (line 81), so keep it. Only remove `CloudDownloadOutlined` and `TreeSelect` from imports.
-
-Change antd import line (line 2-14):
-- Remove `TreeSelect` from the destructured import list.
-
-Change icons import line 15:
-- Remove `CloudDownloadOutlined` from the import.
-
-- [ ] **Step 2: Remove sync-related state and functions**
-
-Remove these state declarations (lines ~33, 37-38):
-- `const [syncing, setSyncing] = useState(false);`
-- `const [syncDeptId, setSyncDeptId] = useState(undefined);`
-- `const [orgTree, setOrgTree] = useState<...>([]);`
-
-Remove these functions:
-- `loadOrgTree` (lines ~40-53)
-- `handleSyncDingTalk` (lines ~96-113)
-- `handleMarkStaff` (lines ~187-196)
-
-- [ ] **Step 3: Remove sync button and TreeSelect from JSX**
-
-In the toolbar `` (lines ~332-368):
-- Remove the `TreeSelect` block (lines ~333-342)
-- Remove the `PermissionButton` with `permission="sync:trigger"` (lines ~351-358)
-
-- [ ] **Step 4: Remove mark-staff/mark-student buttons from columns**
-
-In the `columns` useMemo (lines ~298-307), remove the two conditional blocks:
-- Remove lines ~298-302: `record.studentStatus === 'active'` → "标记教职工" button
-- Remove lines ~303-307: `record.studentStatus === 'staff'` → "恢复学员" button
-
-Adjust the `width` of the 操作 column from `320` to `240` since we're removing two buttons.
-
-- [ ] **Step 5: Build check**
-
-```bash
-cd apps/admin && npx tsc --noEmit
-```
-
-Expected: no new errors.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add apps/admin/src/pages/Users/index.tsx
-git commit -m "refactor: remove sync and mark-staff buttons from Users page"
-```
-
----
-
-### Task 5: Frontend — IntegrationConfig sync users Tab + Drawer
-
-**Files:**
-- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`
-
-**Interfaces:**
-- Consumes: `GET /sync/dingtalk/org-tree-with-users`, `POST /sync/dingtalk/import-users`, `GET /rbac/roles`
-- Produces: SyncUsersTab component with org tree Drawer, teacher selection, role assignment
-
-- [ ] **Step 1: Add new imports**
-
-Add to existing antd imports: `Tabs`, `Drawer`, `Tree`, `Checkbox`, `Select`, `TreeSelect`.
-
-Add icons: `SyncOutlined`, `ReloadOutlined`.
-
-Current import block (lines 1-8):
-
-```typescript
-import React, { useEffect, useState } from 'react';
-import {
- Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
-} from 'antd';
-import {
- SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
-} from '@ant-design/icons';
-import api from '../../api';
-```
-
-Replace with:
-
-```typescript
-import React, { useEffect, useState, useMemo } from 'react';
-import {
- Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
- Tabs, Drawer, Tree, Checkbox, Select, TreeSelect,
-} from 'antd';
-import {
- SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
- SyncOutlined, ReloadOutlined,
-} from '@ant-design/icons';
-import type { DataNode } from 'antd/es/tree';
-import api from '../../api';
-```
-
-- [ ] **Step 2: Add types and state for sync tab**
-
-After the existing `IntegrationConfigPage` component declaration, add new state:
-
-```typescript
- // ── Sync Users Tab ──
- const [syncRootDeptId, setSyncRootDeptId] = useState(undefined);
- const [orgTree, setOrgTree] = useState([]);
- const [drawerOpen, setDrawerOpen] = useState(false);
- const [fetchingTree, setFetchingTree] = useState(false);
- const [importing, setImporting] = useState(false);
- const [roles, setRoles] = useState>([]);
- const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState(null);
- // Department tree for the picker (no users)
- const [deptPickerTree, setDeptPickerTree] = useState }>>([]);
-```
-
-Add the extended tree node type before the component:
-
-```typescript
-interface DingOrgTreeNodeExt {
- id: number;
- name: string;
- parentId: number;
- children: DingOrgTreeNodeExt[];
- users: Array<{ userid: string; name: string; mobile: string }>;
-}
-```
-
-- [ ] **Step 3: Add fetch roles and fetch org tree handlers**
-
-```typescript
- const fetchRoles = async () => {
- try {
- const res: any = await api.get('/rbac/roles');
- const activeRoles = res.filter((r: any) => r.status !== 0);
- setRoles(activeRoles);
- const teacherRole = activeRoles.find((r: any) => r.name === '班主任');
- setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null);
- } catch {
- // ignore — roles will be empty
- }
- };
-
- const loadDeptTree = async () => {
- try {
- const res: any = await api.get('/sync/dingtalk/org-tree');
- if (res.success && res.data) {
- const toTreeNode = (nodes: any[]): any[] =>
- nodes.map((n: any) => ({
- title: n.name,
- value: n.id,
- children: n.children ? toTreeNode(n.children) : undefined,
- }));
- setDeptPickerTree(toTreeNode(res.data));
- }
- } catch {
- // ignore
- }
- };
-
- const handleFetchOrgTree = async () => {
- setFetchingTree(true);
- try {
- const params: Record = {};
- if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
- const res: any = await api.get('/sync/dingtalk/org-tree-with-users', { params });
- if (res.success && res.data) {
- setOrgTree(res.data);
- setTeacherChecks({});
- setTeacherRoles({});
- setDrawerOpen(true);
- } else {
- message.error('获取组织架构失败');
- }
- } catch (e: any) {
- message.error(e?.message || '获取组织架构失败');
- } finally {
- setFetchingTree(false);
- }
- };
-```
-
-- [ ] **Step 4: Add import handler**
-
-```typescript
- const handleImportUsers = async () => {
- setImporting(true);
- try {
- // Flatten all users from tree
- const allUsers: Array<{
- dingUserId: string;
- name: string;
- mobile: string;
- }> = [];
-
- const flatten = (nodes: DingOrgTreeNodeExt[]) => {
- for (const node of nodes) {
- allUsers.push(...node.users);
- flatten(node.children);
- }
- };
- flatten(orgTree);
-
- const payload = {
- users: allUsers.map((u) => ({
- dingUserId: u.userid,
- name: u.name,
- mobile: u.mobile,
- roleId: teacherChecks[u.userid]
- ? (teacherRoles[u.userid] || defaultTeacherRoleId)
- : null,
- })),
- };
-
- const res: any = await api.post('/sync/dingtalk/import-users', payload);
- message.success(
- `导入完成:${res.teacherCount} 位老师,${res.studentCount} 位学生` +
- (res.skipped > 0 ? `,${res.skipped} 已跳过` : ''),
- );
- setDrawerOpen(false);
- } catch (e: any) {
- message.error(e?.message || '导入失败');
- } finally {
- setImporting(false);
- }
- };
-```
-
-- [ ] **Step 5: Build tree data for Drawer**
-
-```typescript
- const buildTreeData = (nodes: DingOrgTreeNodeExt[]): DataNode[] => {
- return nodes.map((node) => ({
- title: node.name,
- key: `dept-${node.id}`,
- children: [
- // Sub-departments
- ...buildTreeData(node.children),
- // Users in this department
- ...node.users.map((u) => ({
- title: (
-
- {
- setTeacherChecks((prev) => ({
- ...prev,
- [u.userid]: e.target.checked,
- }));
- if (!e.target.checked) {
- setTeacherRoles((prev) => {
- const next = { ...prev };
- delete next[u.userid];
- return next;
- });
- }
- }}
- >
- 老师
-
- {u.name}
- {u.mobile && (
- {u.mobile}
- )}
- {teacherChecks[u.userid] && (
-
- ),
- key: `user-${u.userid}`,
- selectable: false,
- })),
- ],
- }));
- };
-```
-
-- [ ] **Step 6: Build tree data memoized**
-
-```typescript
- const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
-```
-
-- [ ] **Step 7: Replace page root with Tabs**
-
-Replace the entire `return (...)` block. The page root becomes:
-
-```typescript
- const syncTabItems = config
- ? [
- {
- key: 'sync-users',
- label: '同步用户',
- children: (
-
-
-
- setSyncRootDeptId(v)}
- placeholder="选择起始部门(不选=全部)"
- allowClear
- treeDefaultExpandAll
- style={{ minWidth: 240 }}
- onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
- />
- }
- loading={fetchingTree}
- onClick={handleFetchOrgTree}
- >
- 获取组织架构
-
-
- {drawerOpen && (
-
setDrawerOpen(false)}
- width={520}
- footer={
-
-
- }
- loading={importing}
- onClick={handleImportUsers}
- >
- 导入
-
-
- }
- >
- {treeData.length > 0 ? (
-
- ) : (
-
- )}
-
- )}
-
- ),
- },
- ]
- : [];
-
- const tabItems = [
- {
- key: 'config',
- children: (
-
- {config && (
-
- {config.corpId || '-'}
- {config.agentId || '-'}
-
-
- {config.startEnable ? '已启用' : '未启用'}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } loading={saving} onClick={handleSave}>
- 保存配置
-
- } loading={testing} onClick={handleTest}>
- 测试连接
-
-
-
-
- ),
- },
- ...syncTabItems,
- ];
-
- return (
-
-
-
- );
-```
-
-- [ ] **Step 8: Add fetchRoles to useEffect**
-
-In the existing `useEffect` (line ~42), add `fetchRoles()` call:
-
-```typescript
- useEffect(() => {
- fetchConfig();
- fetchRoles();
- }, []);
-```
-
-- [ ] **Step 9: Build check**
-
-```bash
-cd apps/admin && npx tsc --noEmit
-```
-
-Expected: no errors.
-
-- [ ] **Step 10: E2E smoke test**
-
-Start the dev server and verify:
-1. Navigate to IntegrationConfig page
-2. "同步用户" Tab only visible when DingTalk is configured
-3. Click "获取组织架构" → Drawer opens with department tree
-4. Check users as teachers → role select appears (defaults to 班主任)
-5. Click "导入" → success message with counts
-
-```bash
-cd apps/admin && npx vite --port 5173 &
-cd apps/server && npm run start:dev &
-```
-
-- [ ] **Step 11: Commit**
-
-```bash
-git add apps/admin/src/pages/IntegrationConfig/index.tsx
-git commit -m "feat: add sync users Tab with Drawer tree to IntegrationConfig"
-```
-
----
-
-### Task 6: Final verification & cleanup
-
-- [ ] **Step 1: Run full type check**
-
-```bash
-cd apps/server && npx tsc --noEmit -p tsconfig.build.json
-cd apps/admin && npx tsc --noEmit
-```
-
-- [ ] **Step 2: Verify Users page no longer shows sync/mark buttons**
-
-Smoke test Users page — confirm no "同步钉钉用户" button, no TreeSelect, and no "标记教职工"/"恢复学员" in the actions column.
-
-- [ ] **Step 3: Verify IntegrationConfig sync flow end-to-end**
-
-Run through the full flow:
-1. Config page → Sync Users tab
-2. Fetch org tree → Drawer shows tree
-3. Check teachers → role dropdown works
-4. Import → correct counts returned
-5. Verify in DB: teachers have roles, students have Student records
-
-- [ ] **Step 4: Commit any remaining changes**
-
-```bash
-git add -A
-git commit -m "chore: final verification and cleanup for dingtalk sync role selection"
-```
diff --git a/docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md b/docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md
deleted file mode 100644
index c06dcc0..0000000
--- a/docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md
+++ /dev/null
@@ -1,174 +0,0 @@
-# Org Tree Deepest Levels — 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:** `fetchOrgTreeWithUsers` only returns departments at the deepest 2 levels of the global org tree.
-
-**Architecture:** Add a `getDeptDepthMap` BFS method that mirrors `getAllDeptIds` but tracks level. Insert depth filtering in `fetchOrgTreeWithUsers` before the detail+user fetch loop, slashing API calls.
-
-**Tech Stack:** NestJS + TypeScript, DingTalk Open API v2
-
-## Global Constraints
-
-- Depth is computed from rootDeptId=1 globally, regardless of the user's `rootDeptId` param
-- `fetchOrgTree` (dept picker) is NOT modified
-- `syncAll` is NOT modified
-- Frontend receives the same `DingOrgTreeNodeWithUsers[]` shape, zero frontend changes
-
----
-
-### Task 1: Add `getDeptDepthMap` method
-
-**Files:**
-- Modify: `apps/server/src/integration/dingtalk.service.ts` (insert after `getAllDeptIds`)
-
-**Interfaces:**
-- Consumes: nothing new (uses existing `rateLimit()`, `SubDeptIdListResponse`, DingTalk `listsubid` API)
-- Produces: `private async getDeptDepthMap(token: string): Promise