diff --git a/apps/server/package.json b/apps/server/package.json index 2ca0a5f..d40c28b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "echarts": "^6.1.0", "exceljs": "^4.4.0", "multer": "^2.1.1", "mysql2": "^3.22.2", diff --git a/apps/server/src/integration/integration.module.ts b/apps/server/src/integration/integration.module.ts new file mode 100644 index 0000000..e4f01dc --- /dev/null +++ b/apps/server/src/integration/integration.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Department, User } from '../entities'; +import { DingTalkService } from './dingtalk.service'; +import { WeComService } from './wecom.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Department, User])], + providers: [DingTalkService, WeComService], + exports: [DingTalkService, WeComService], +}) +export class IntegrationModule {} diff --git a/apps/server/src/sync/sync.module.ts b/apps/server/src/sync/sync.module.ts index f748401..58173ce 100644 --- a/apps/server/src/sync/sync.module.ts +++ b/apps/server/src/sync/sync.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { IntegrationModule } from '../integration/integration.module'; import { SyncLog, SyncState } from '../entities'; import { SyncService } from './sync.service'; import { SyncController } from './sync.controller'; @@ -9,6 +10,7 @@ import { SyncController } from './sync.controller'; imports: [ ScheduleModule.forRoot(), TypeOrmModule.forFeature([SyncLog, SyncState]), + IntegrationModule, ], controllers: [SyncController], providers: [SyncService], diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index de02350..9424190 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -4,6 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { SyncLog, SyncState } from '../entities'; import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity'; +import { DingTalkService } from '../integration/dingtalk.service'; +import { WeComService } from '../integration/wecom.service'; @Injectable() export class SyncService { @@ -14,6 +16,8 @@ export class SyncService { private readonly syncLogRepo: Repository, @InjectRepository(SyncState) private readonly syncStateRepo: Repository, + private readonly dingTalkService: DingTalkService, + private readonly weComService: WeComService, ) {} // ── Scheduled cron: daily at 2 AM ── @@ -43,9 +47,10 @@ export class SyncService { await this.updateLastSyncAt(platform); await this.finishSyncLog(log, 'success', recordsCount); this.logger.log(`DingTalk sync complete: ${recordsCount} records`); - } catch (error: any) { - await this.finishSyncLog(log, 'failed', 0, error.message); - this.logger.error(`DingTalk sync failed: ${error.message}`, error.stack); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + await this.finishSyncLog(log, 'failed', 0, message); + this.logger.error(`DingTalk sync failed: ${message}`, error instanceof Error ? error.stack : undefined); } return log; @@ -69,9 +74,10 @@ export class SyncService { await this.updateLastSyncAt(platform); await this.finishSyncLog(log, 'success', recordsCount); this.logger.log(`WeCom sync complete: ${recordsCount} records`); - } catch (error: any) { - await this.finishSyncLog(log, 'failed', 0, error.message); - this.logger.error(`WeCom sync failed: ${error.message}`, error.stack); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + await this.finishSyncLog(log, 'failed', 0, message); + this.logger.error(`WeCom sync failed: ${message}`, error instanceof Error ? error.stack : undefined); } return log; @@ -86,7 +92,7 @@ export class SyncService { // ── Sync log queries ── async getLogs(platform?: SyncPlatform, limit: number = 50): Promise { - const where: any = {}; + const where: Record = {}; if (platform) where.platform = platform; return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit }); } @@ -145,36 +151,13 @@ export class SyncService { await this.syncLogRepo.save(log); } - // ── Integration stubs — replace with real API calls ── - - /** - * Perform the actual DingTalk data pull. - * Pass `lastSyncAt` to the DingTalk API for incremental sync. - */ private async performDingTalkSync(_lastSyncAt: Date | null): Promise { - const appKey = process.env.DINGTALK_APP_KEY; - if (!appKey) { - this.logger.warn('DingTalk not configured (DINGTALK_APP_KEY missing), skipping sync'); - return 0; - } - // 接入指引: - // 1. 创建 apps/server/src/integration/dingtalk.service.ts,实现 fetchDepartments()/fetchUsers() - // 2. 在 SyncModule 中注入 DingTalkService - // 3. 取消以下注释并调用 this.dingTalkService.fetchDepartments() - this.logger.warn('DingTalk sync stub: create DingTalkService in src/integration/ to enable real sync'); - return 0; + const result = await this.dingTalkService.syncAll(); + return result.deptCount + result.userCount; } private async performWeComSync(_lastSyncAt: Date | null): Promise { - const corpId = process.env.WECOM_CORP_ID; - if (!corpId) { - this.logger.warn('WeCom not configured (WECOM_CORP_ID missing), skipping sync'); - return 0; - } - - this.logger.warn( - 'WeCom integration service not yet implemented — add WeComService to SyncModule to enable real sync', - ); - return 0; + const result = await this.weComService.syncAll(); + return result.deptCount + result.userCount; } } diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..308eb4f --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,1061 @@ +# 恭学教育学生管理系统 — 产品需求文档 (PRD) + +> 版本:v1.0 +> 日期:2026-07-03 +> 状态:待评审 +> 基于代码审查 commit:`59f75bb` + +--- + +## 目录 + +1. [系统概述](#1-系统概述) +2. [域模型全景图](#2-域模型全景图) +3. [模块 1:学生管理](#3-模块-1学生管理) +4. [模块 2:学生档案](#4-模块-2学生档案) +5. [模块 3:班级管理](#5-模块-3班级管理-新模块) +6. [模块 4:排课管理](#6-模块-4排课管理-新模块) +7. [模块 5:教师管理](#7-模块-5教师管理) +8. [模块 6:教室管理](#8-模块-6教室管理) +9. [模块 7:宿舍管理](#9-模块-7宿舍管理) +10. [模块 8:入住管理](#10-模块-8入住管理) +11. [模块 9:费用管理](#11-模块-9费用管理) +12. [模块 10:账单管理](#12-模块-10账单管理) +13. [模块 11:押金管理](#13-模块-11押金管理) +14. [模块 12:租赁方管理](#14-模块-12租赁方管理) +15. [模块 13:考勤管理](#15-模块-13考勤管理) +16. [模块 14:数据面板](#16-模块-14数据面板) +17. [横切关注点:RBAC 权限](#17-横切关注点rbac-权限) +18. [横切关注点:操作日志](#18-横切关注点操作日志) +19. [横切关注点:第三方集成](#19-横切关注点第三方集成) +20. [横切关注点:报表导出](#20-横切关注点报表导出) +21. [现有代码 vs 目标状态 差距总览](#21-现有代码-vs-目标状态-差距总览) +22. [上线优先级排序](#22-上线优先级排序) +23. [待澄清问题](#23-待澄清问题) + +--- + +## 1. 系统概述 + +### 1.1 项目定位 + +恭学教育学生管理系统是一个面向教培集训基地的综合管理平台,覆盖**学员管理、教务排课、住宿运营、财务计费**四大业务域,支撑从学员入学、分班、排课、考勤、住宿、计费到结课归档的全生命周期管理。 + +### 1.2 核心用户角色 + +| 角色 | 职责 | 数据范围 | +|------|------|----------| +| 超级管理员 | 系统配置、账号管理、角色分配 | 全部数据 | +| 教职工(宿管+财务) | 宿舍管理、费用录入、账单生成、押金管理 | 指定部门 + 子部门 | +| 班主任 | 查看本班学生信息、档案、费用、考勤 | 本班及下级班级 | +| 学生 | 查看个人信息、账单、押金 | 仅本人 | + +默认导入的用户都是学生权限,需要更高权限往往是由超管去为该角色分配。权限的配置很灵活,因为涉及多种老师,生活老师(宿管)、班主任(可看考勤和管理自己班级的学生) + +### 1.3 技术栈 + +| 层级 | 技术 | +|------|------| +| 架构 | Monorepo (Turborepo) | +| 前端 | React 19 + Vite + Ant Design 6 + ECharts | +| 后端 | NestJS 11 + TypeORM 0.3 + JWT + Passport | +| 数据库 | 开发 SQLite / 生产 MySQL 8 | +| 集成 | 钉钉开放平台 API + 企业微信 API | +| 部署 | Docker Compose (MySQL + NestJS + Nginx) | +| 测试 | Jest (后端) + Playwright (前端 E2E) | + +--- + +## 2. 域模型全景图 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ 恭学教育学生管理系统 │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ +│ │ 学员管理域 │ │ 教务管理域 │ │ 住宿管理域 │ │ 财务运营域│ │ +│ │ │ │ │ │ │ │ │ │ +│ │ 1.学生管理 │ │ 3.班级管理 🆕 │ │ 7.宿舍管理 │ │ 9.费用管理│ │ +│ │ 2.学生档案 │ │ 4.排课管理 🆕 │ │ 8.入住管理 │ │ 10.账单管理│ │ +│ │ 13.考勤管理 │ │ 5.教师管理 │ │ │ │ 11.押金管理│ │ +│ │ │ │ 6.教室管理 │ │ │ │ 12.租赁方 │ │ +│ │ │ │ │ │ │ │ 14.数据面板│ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 横切:RBAC权限 · 操作日志 · 钉钉/企微集成 · 报表/导出· 导入模板│ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 核心实体关系 + +``` +User ──┬── Student ──┬── StudentProfile (1:1) + │ ├── StudentEnrollment (1:N) ── 关联 Class + │ ├── ExamScore (1:N) + │ ├── AttendanceRecord (1:N) ←── DingAttendanceRaw + │ ├── LearningRecord (1:N) + │ ├── ResultArchive (1:N) + │ ├── ArchiveAttachment (1:N) + │ ├── StudentReport (1:N) + │ ├── Occupancy (1:N) ── Room + │ ├── PersonalExpense (1:N) + │ └── Bill (1:N) ── BillItem (1:N) + │ + ├── Department ──┬── DepartmentCommander (用户-部门负责人) + │ └── DepartmentSubstitute (教师代管) + │ + └── Role ── RolePermission ── Menu + +Class (🆕) ──┬── ClassStudent ── Student + ├── ClassTeacher ── User + └── ClassSchedule (🆕) ── Classroom + +Room ──┬── Occupancy ── Student + ├── RoomExpense + └── (rental_category: long/short) + +Classroom ──┬── ClassroomRental ── Tenant + └── ClassSchedule (🆕) ── Class + +Tenant ──┬── ClassroomRental + └── Occupancy.tenant_id (🆕) +``` + +--- + +## 3. 模块 1:学生管理 + +### 3.1 概述 + +管理所有学员的基本信息,是系统最核心的基础数据。学生与 `User`(登录账号)通过 `user_id` 一对一关联,与钉钉/企微用户通过 `resource_user_id` 关联。 + +### 3.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| 数据模型 `students` | ✅ | +| 后端 CRUD API | ✅ | +| Excel 批量导入/导出 | ✅ | +| 前端列表/搜索/筛选 | ✅ | +| 与 User 关联 (userId) | ✅ | +| 与钉钉/企微关联 (resourceUserId) | ✅ | + +### 3.3 数据模型 + +``` +students +├── id (PK) +├── user_id FK → users.id (UNIQUE) +├── name, phone, id_card (INDEX), student_no (INDEX) +├── gender, ethnicity +├── emergency_contact, emergency_phone +├── status (active/inactive/graduated) +├── organization → 🆕 改为 tenant_id FK → tenants.id +├── supervisor (负责人) +├── resource_user_id (钉钉/企微 userid) +├── department_id FK → sys_department.id +├── created_at, updated_at +``` + +### 3.4 待实现 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 1.1 | `organization` 改为 `tenant_id` 外键 | P1 | 当前是纯文本,无法关联 Tenant 表做筛选统计 | +| 1.2 | 按机构维度筛选学生 | P1 | 依赖 1.1 | +| 1.3 | 敏感信息查看需二次确认 + 日志 | P1 | phone/idCard 脱敏展示,点击查看需确认弹窗 + 记录操作日志 | + +各种涉及表单或者列表或是大量数据的页面都需要有非常详细的筛选方案,方便我们快速筛查检索需要的信息,或批量进行控制。 + +### 3.5 用户故事 + +- 教务老师通过 Excel 模板批量导入 200 名历史学员 +- 从钉钉同步后,自动创建 Student 档案并关联 User +- 在学员列表按"合作机构 A"筛选,导出该机构所有在读学员 + +### 3.6 状态说明 + +``` +active — 在读 +inactive — 已离校 +graduated — 已毕业/结课 +``` + +--- + +## 4. 模块 2:学生档案 + +### 4.1 概述 + +学生档案是学员全生命周期数据的聚合视图,包含报读信息、考试成绩、出勤记录、学情记录、最终录取结果、附件、报告版本。 + +### 4.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| 8 张档案子表 CRUD | ✅ | +| 档案聚合查询 API | ✅ | +| PDF 报表生成(Chrome headless) | ✅ | +| HTML 报表预览 | ✅ | +| Excel 模板导入(预览→确认两步) | ✅ | +| 敏感信息脱敏(身份证/电话) | ✅ | +| 出勤汇总 + 考试汇总 | ✅ | + +### 4.3 数据模型(全部已实现) + +``` +student_profiles — 扩展档案(院校/专业/科类/年级/校区/建档日期) +student_enrollments — 报读记录(课程类别/班型/班级/班主任/任课老师/开结课日期) +exam_scores — 考试成绩(类型/名称/科目/分数/班级平均/排名) +attendance_records — 出勤记录(日期/时段/状态/来源/备注) +learning_records — 学情记录(日期/类型/内容/跟进方式/下一步) +result_archives — 录取归档(文化课/专业课最终成绩/录取状态/院校/专业) +archive_attachments — 附件(成绩截图/录取截图/协议等) +student_reports — 报告版本(快照数据/PDF/HTML/生成时间) +``` + +### 4.4 待优化 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 2.1 | 多班型(3+ enrollment)档案封面展示不完整 | P2 | 当前只展示前 2 个 enrollment 的卡片 | +| 2.2 | 报表支持"多班型对比视图" | P2 | 同一学生文化课 vs 专业课独立对比报表 | + +### 4.5 报表内容(多页 A4) + +1. **封面**:学员姓名、身份证号、科类方向、报考院校/专业、班主任、建档日期 +2. **基础信息**:个人信息 + 升学意向 + 文化课报读 + 专业课报读 +3. **入学测评与阶段概览**:入学测试总分、阶段最高分、总分提升、趋势 SVG 图 +4. **出勤记录与过程跟踪**:出勤率、请假/缺勤统计、出勤明细矩阵、出勤记录需要覆盖整个学习周期 +5. **文化课测评成绩**:周测/月测/模考总分表、冲刺分科进度条、分科观察 +6. **专业课测评与阶段记录**:模考总分、分项提升柱状图、学情记录、待补充归档清单 + +--- + +## 5. 模块 3:班级管理 🆕 新模块 + +### 5.1 概述 + +班级是排课、考勤、教师分配的核心组织单元。当前系统用 `Department`(type="class")间接承载班级概念,需独立建模。 + +### 5.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| Department.type = "class" 表示班级 | ⚠️ 间接实现 | +| DepartmentCommander 记录班级负责人 | ⚠️ 间接实现 | +| StudentEnrollment 包含 classType/className | ⚠️ 文本字段 | + +### 5.3 需要新增的数据模型 + +``` +class(班级,表名待定避免关键字冲突,如 classes / sys_class) +├── id (PK) +├── name — 班级名称,如 "2026届文化课冲刺1班" +├── code — 班级编码(唯一) +├── department_id FK — 所属校区/部门 +├── class_type — 班型:文化课 / 专业课 / 集训营 / 冲刺营 +├── start_date — 开班日期 +├── end_date — 结课日期 +├── status — 招生中 / 在读 / 结课 / 停课 +├── head_teacher_id FK → users (班主任) +├── life_teacher_id FK → users (生活老师) +├── academic_teacher_id FK → users (学服老师) +├── max_students — 班级人数上限 +├── notes +├── created_at, updated_at + +class_student(班级学员关联) +├── id (PK) +├── class_id FK +├── student_id FK +├── enrollment_id FK — 关联 student_enrollments +├── join_date, leave_date +├── status (active/left) +├── created_at + +class_teacher(班级教师关联) +├── id (PK) +├── class_id FK +├── user_id FK +├── role_type — 任课老师 / 班主任 / 生活老师 / 学服老师 +├── subject — 任教科目(任课老师时必填) +├── created_at +``` + +### 5.4 用户故事 + +| 编号 | 故事 | +|:---:|------| +| 3.1 | 教务老师创建"2026届文化课冲刺1班",指定班主任张三、生活老师李四、学服老师王五 | +| 3.2 | 为该班级批量添加 35 名学员 | +| 3.3 | 为该班级分配任课教师:语文-赵老师、数学-钱老师、英语-孙老师 | +| 3.4 | 查看班级花名册(学员列表 + 教师列表) | +| 3.5 | 查看班级出勤汇总(出勤率/缺勤率/迟到率) | + +### 5.5 API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/classes` | 班级列表(支持按 department_id/status/class_type 筛选) | +| POST | `/classes` | 创建班级 | +| GET | `/classes/:id` | 班级详情(含教师列表、学员统计) | +| PUT | `/classes/:id` | 编辑班级 | +| DELETE | `/classes/:id` | 删除班级 | +| GET | `/classes/:id/students` | 班级学员列表 | +| POST | `/classes/:id/students` | 添加学员到班级 | +| DELETE | `/classes/:id/students/:studentId` | 移除学员 | +| GET | `/classes/:id/teachers` | 班级教师列表 | +| POST | `/classes/:id/teachers` | 添加教师到班级 | +| DELETE | `/classes/:id/teachers/:userId` | 移除教师 | +| GET | `/classes/:id/schedule` | 班级课表 | +| GET | `/classes/:id/attendance-summary` | 班级出勤汇总 | + +--- + +## 6. 模块 4:排课管理 🆕 新模块 + +### 6.1 概述 + +管理内部班级在教室中的排课安排。与外部租赁不同:外部租赁只需知道"哪个教室被哪个租赁方占用",内部排课需要知道"哪个班级、什么科目、哪位教师、在哪个教室、什么时段"。 + +### 6.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| 排课实体/API/页面 | ❌ 完全缺失 | +| StudentEnrollment.scheduleType | ⚠️ 仅为文本字段 | + +### 6.3 需要新增的数据模型 + +``` +class_schedule(排课记录) +├── id (PK) +├── class_id FK — 关联班级 +├── classroom_id FK — 关联教室 +├── week_day — 星期几 (1=周一 ~ 7=周日) +├── start_time — 开始时间 (HH:mm) +├── end_time — 结束时间 (HH:mm) +├── start_date — 排课生效开始日期 +├── end_date — 排课生效结束日期 +├── subject — 科目 +├── teacher_id FK → users — 任课教师 +├── schedule_type — INTERNAL(内部排课)/ RENTAL(外部租赁,关联 ClassroomRental) +├── rental_id FK — 当 schedule_type=RENTAL 时关联 classroom_rentals.id +├── status — active / cancelled +├── notes +├── created_at, updated_at +``` + +**唯一约束:** 同一教室 + 同一 week_day + 时间段重叠 + status=active 不能有两条记录(排课冲突检测)。 + +### 6.4 核心规则 + +1. **冲突检测**:新增排课时检查同一教室同一时间段是否已被占用 +2. **INTERNAL vs RENTAL**: + - 内部排课:按班级维度管理,展示科目+教师 + - 外部租赁:仅标记教室占用,不涉及班级/教师信息 +3. **排课视图**: + - 周视图(7 天 × 时间段 矩阵,每个格子显示科目+教师) + - 月视图(日历形式,点击日期展开当日排课) + +### 6.5 用户故事 + +| 编号 | 故事 | +|:---:|------| +| 4.1 | 教务老师为"冲刺1班"在 301 教室安排「周一至周五 8:00-12:00 语文课」,教师赵老师 | +| 4.2 | 系统检测到 301 教室周一 8:00-10:00 已被"基础班"占用,提示冲突 | +| 4.3 | 班主任打开本班课表,看到一周的课程安排 | +| 4.4 | 管理员打开教室占用视图,看到每个教室各时段的使用情况(内部排课 + 外部租赁) | + +### 6.6 API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/class-schedules` | 排课列表(支持 classroom_id/class_id/week_day/date 筛选) | +| POST | `/class-schedules` | 创建排课(含冲突检测) | +| PUT | `/class-schedules/:id` | 编辑排课 | +| DELETE | `/class-schedules/:id` | 删除排课 | +| GET | `/class-schedules/weekly` | 周视图数据(按教室+星期几聚合) | +| GET | `/class-schedules/classroom/:id/occupancy` | 某教室的占用时间线 | + +--- + +## 7. 模块 5:教师管理 + +### 7.1 概述 + +教师是 User 的一种角色,需要管理其与班级的关联、任教学科信息。 + +### 7.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| User 表 + 角色(class_teacher/staff) | ✅ | +| DepartmentCommander(部门-用户负责人) | ✅ | +| DepartmentSubstitute(教师代管) | ✅ | +| 教师档案(学科/资质) | ❌ | +| 教师-班级显式关联 | ❌ | + +### 7.3 待增强 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 5.1 | `user_extend` 增加教师档案字段 | P2 | 任教学科、入职日期、资质等(JSON 扩展或独立字段) | +| 5.2 | 班级模块中的 `class_teacher` 建立显式关联 | P0 | 依赖班级模块 | +| 5.3 | 教师工作台视图 | P2 | 查看自己所带班级课表、学员列表、出勤统计 | + +### 7.4 教师角色的业务含义 + +| 角色类型 | 职责 | +|----------|------| +| 任课老师 | 负责特定科目的教学,关联到班级+科目 | +| 班主任 (head_teacher) | 班级主要负责人,关注学员全面情况 | +| 生活老师 | 负责学员日常起居管理 | +| 学服老师 | 学习服务跟进,学情记录 | + +--- + +## 8. 模块 6:教室管理 + +### 8.1 概述 + +管理教室资源,区分内部使用和外部租赁两种场景。 + +### 8.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| classrooms 表 + CRUD | ✅ | +| 教室列表/筛选 | ✅ | +| 教室租赁排期可视化 (ClassroomSchedule) | ✅ | + +### 8.3 数据模型(现有) + +``` +classrooms +├── id (PK) +├── name, building, floor, capacity +├── room_type (大/次大/小) +├── course_type, supervisor +├── status (available/in_use/maintenance) +├── notes, created_at +``` + +### 8.4 待增强 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 6.1 | 教室占用状态整合排课数据 | P1 | 当前只看外部租赁,需合并内部排课计算综合占用率 | +| 6.2 | ClassroomSchedule 区分内部排课 vs 外部租赁 | P1 | 颜色/标签区分两种使用类型 | +| 6.3 | 教室利用率统计 | P2 | 内部 + 外部的综合指标 | + +--- + +## 9. 模块 7:宿舍管理 + +### 9.1 概述 + +管理宿舍房间资源,支持长租和短租两种模式,支持合作机构标记。 + +### 9.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| rooms 表 + CRUD | ✅ | +| 宿舍总览可视化 (RoomVisual) | ✅ | +| 长租/短租区分 | ❌ | +| 合作机构标记 | ❌ | + +### 9.3 数据模型变更 + +``` +rooms (现有字段 + 新增) +├── room_number, building, floor, capacity, status, gender +├── room_type (现有,文本) +├── 🆕 rental_category — long / short (长租/短租) +├── 🆕 monthly_rate — 月租金(长租时使用,decimal) +├── created_at + +occupancies (现有字段 + 新增) +├── student_id, room_id +├── check_in_date, check_out_date +├── billing_start_date, billing_end_date +├── check_out_reason, notes +├── 🆕 rental_type — long / short (继承自 Room 或独立设置) +├── 🆕 tenant_id FK → tenants.id (合作机构) +├── created_at +``` + +### 9.4 业务规则 + +| 场景 | 规则 | +|------|------| +| 长租 | 按月固定费用计费,不参与人天数分摊,独立生成月度账单 | +| 短租 | 按天计费,参与人天数加权分摊 | +| 混合宿舍 | 同一宿舍可能有长租+短租学生,分摊时分别处理 | + +### 9.5 待实现 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 7.1 | Room 增加 rental_category + monthly_rate | P0 | 数据模型变更 + 迁移脚本 | +| 7.2 | Occupancy 增加 rental_type + tenant_id | P0 | 数据模型变更 | +| 7.3 | 宿舍总览页增加机构颜色标记 | P1 | 按 tenant.color 着色 | +| 7.4 | 长租独立账单生成逻辑 | P0 | 账单生成时区分 rental_type | + +### 9.6 用户故事 + +- 宿管老师将 401 设为"长租宿舍",月费 800 元/人,402 为"短租宿舍" +- 学生 A(来自机构 X)入住 401 长租,学生 B(来自机构 Y)入住 402 短租 +- 宿舍总览页以机构颜色区分显示 +- 生成账单时,401 长租学生生成「月固定费用」账单,402 短租学生参与人天数分摊 + +--- + +## 10. 模块 8:入住管理 + +### 10.1 现有实现 + +| 项目 | 状态 | +|------|:---:| +| occupancies 表 + CRUD | ✅ | +| 入住/退住日期管理 | ✅ | +| 计费起止日期独立管理 | ✅ | + +### 10.2 待优化 + +| 编号 | 需求 | 优先级 | +|:---:|------|:---:| +| 8.1 | 入住时自动拉取学生当前 enrollment 信息 | P2 | +| 8.2 | 批量退住操作 | P2 | +| 8.3 | 入住历史时间线视图 | P2 | + +--- + +## 11. 模块 9:费用管理 + +### 11.1 现有实现 + +| 项目 | 状态 | +|------|:---:| +| room_expenses(宿舍公摊费) | ✅ | +| personal_expenses(个人附加费) | ✅ | + +### 11.2 费用类型(当前支持) + +``` +water — 水费 +electricity — 电费 +cleaning — 保洁费 +damage — 损坏赔偿 +penalty — 罚款 +key — 钥匙费 +remote — 空调遥控器 +deposit_deduction — 押金扣除 +other — 其他 +``` + +### 11.3 待优化 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 9.1 | 费用类型可配置化 | P2 | 当前硬编码,支持管理员自定义费用类型 | + +--- + +## 12. 模块 10:账单管理 + +### 12.1 核心计费算法 + +**人天数加权分摊:** + +1. 取某宿舍在计费周期内的所有入住记录 +2. 计算每个学生的计费天数(billingStartDate ~ billingEndDate 与 周期 overlap) +3. 每项房间费用按「该学生人天数 / 总人天数」比例分摊 +4. 加上学生的个人附加费 → 账单总额 + +### 12.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| 人天数加权分摊引擎 | ✅ | +| 押金联动(自动计算押金抵扣) | ✅ | +| 账单状态管理(draft/confirmed/paid) | ✅ | +| Excel 导出 | ✅ | + +### 12.3 待实现 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 10.1 | 长租模式账单生成 | P0 | 按月固定费直接生成,不参与分摊 | +| 10.2 | 账单通知推送 | P2 | 钉钉/企微通知学生账单已生成 | + +--- + +## 13. 模块 11:押金管理 + +### 13.1 现有实现 + +| 项目 | 状态 | +|------|:---:| +| deposits 表 + CRUD | ✅ | +| 押金-账单联动 | ✅ | + +### 13.2 待优化 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 11.1 | 押金分期管理 | P2 | 多次入住/退住场景的押金历史追溯 | +| 11.2 | 押金退还审批流 | P2 | 多级确认 | + +--- + +## 14. 模块 12:租赁方管理 + +### 14.1 现有实现 + +| 项目 | 状态 | +|------|:---:| +| tenants 表 + CRUD | ✅ | +| 颜色标记(可视化) | ✅ | + +### 14.2 数据模型(现有) + +``` +tenants +├── id (PK) +├── name, contact, phone +├── color (可视化颜色 hex) +├── notes, status (active/archived) +├── created_at, updated_at +``` + +--- + +## 15. 模块 13:考勤管理 + +### 15.1 概述 + +考勤数据有两个来源: +1. **钉钉打卡机自动同步**:Stream 事件 → ding_attendance_raw → 匹配 Student → attendance_records +2. **人工补录**:通过系统手动添加考勤记录 + +### 15.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| 钉钉 Stream 打卡事件处理 | ✅ | +| 原始数据落库 ding_attendance_raw | ✅ | +| Student 匹配(resourceUserId) | ✅ | +| 出勤记录写入 attendance_records | ✅ | +| 人工补录 API(StudentArchives 子模块) | ✅ | +| **前端考勤管理页面** | ❌ | +| 考勤统计 | ⚠️ 仅个人档案中有汇总 | + +### 15.3 数据模型(全部已实现) + +``` +attendance_records +├── student_id, attendance_date, session (早自习/上午/下午/晚自习/晚寝) +├── course_name, expected_time, actual_time +├── status (出勤/迟到/早退/事假/病假/缺勤/未填) +├── leave_type, late_minutes +├── source (钉钉/人工点名/Excel导入/系统补录) +├── source_record_id, confirmed_by, remark +├── created_at, updated_at + +ding_attendance_raw +├── ding_user_id, phone, source_record_id +├── check_time, raw_status +├── match_status (未处理/已匹配/待匹配) +├── raw_json (完整钉钉推送数据) +├── created_at +``` + +### 15.4 待实现 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 13.1 | **前端考勤管理页面** | P1 | 按日期/班级/状态筛选、批量补录、查看原始钉钉数据 | +| 13.2 | 考勤日历视图 | P1 | 类似 ClassroomSchedule 的矩阵布局,行=学生,列=日期+时段 | +| 13.3 | 班级考勤总览 | P1 | 按班级统计出勤率/缺勤率/迟到率,支持导出 | +| 13.4 | 异常考勤预警 | P2 | 连续缺勤/迟到超过阈值自动标记 | +| 13.5 | 未匹配钉钉数据处理 | P1 | 展示 match_status=待匹配 的数据,手动关联到 Student | +| 13.6 | 考勤数据纳入 Dashboard | P1 | 全局出勤率、缺勤趋势 | + +### 15.5 前端页面设计 + +**考勤管理页:** + +- 顶部筛选栏:班级、日期范围、时段、状态、来源 +- 数据表格:姓名、班级、日期、时段、状态、来源、打卡时间、备注 +- 批量操作:批量标记出勤/请假 +- 切换到日历视图按钮 + +**考勤日历视图:** + +``` + 周一 周二 周三 周四 周五 +张三 到 到 假 到 到 +李四 到 到 到 迟 到 +王五 缺 到 到 到 到 +``` + +### 15.6 API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/attendance-records` | 考勤列表(已有,需增强筛选参数) | +| POST | `/attendance-records` | 新增考勤记录(已有) | +| PUT | `/attendance-records/:id` | 编辑考勤记录(已有) | +| DELETE | `/attendance-records/:id` | 删除考勤记录(已有) | +| POST | `/attendance-records/batch` | 🆕 批量补录 | +| GET | `/attendance-records/summary` | 🆕 考勤汇总统计 | +| GET | `/attendance-records/calendar` | 🆕 日历视图数据 | +| GET | `/ding-attendance-raw` | 🆕 钉钉原始数据列表 | +| POST | `/ding-attendance-raw/:id/match` | 🆕 手动匹配钉钉数据到学生 | + +--- + +## 16. 模块 14:数据面板 + +### 16.1 概述 + +系统总览仪表盘,用可视化图表展示各业务域关键指标。 + +### 16.2 现有实现 + +| 项目 | 状态 | +|------|:---:| +| 4 个宿舍指标卡 | ✅ | +| 宿舍入住甘特图 | ✅ | +| 费用类型饼图 | ✅ | +| 宿舍费用排行 | ✅ | +| 教室/考勤/收入指标 | ❌ | + +### 16.3 待实现 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| 14.1 | 第二行指标卡 | P1 | 教室总数/占用率、本月出勤率、本月收入总额 | +| 14.2 | 教室排期热力图 | P2 | 各教室本月占用率颜色热力 | +| 14.3 | 考勤趋势线 | P1 | 近 30 天全局出勤率变化 | +| 14.4 | 收入趋势图 | P1 | 近 6 个月账单收入趋势(宿舍+教室租赁) | +| 14.5 | 班级出勤率排行 | P2 | TOP/BOTTOM 班级出勤率 | + +### 16.4 目标布局 + +``` +┌──────────────┬──────────────┬──────────────┬──────────────┐ +│ 宿舍总数 │ 在读学员 │ 当前在住 │ 入住率 │ +│ 120 │ 856 │ 342 / 480 │ 71.3% │ +├──────────────┼──────────────┼──────────────┼──────────────┤ +│ 教室总数 │ 今日出勤率 │ 出勤学生 │ 教室占用率 │ +│ 28 │ 92.5% │ 666 人 │ 64.8% │ +└──────────────┴──────────────┴──────────────┴──────────────┘ + +┌─────────────────────────┐ ┌─────────────────────────┐ +│ 入住时间线(甘特图) │ │ 考勤趋势(折线图) │ +└─────────────────────────┘ └─────────────────────────┘ + +┌─────────────────────────┐ ┌─────────────────────────┐ +│ 费用类型分布(饼图) │ │ 教室占用趋势(柱状图) │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +--- + +## 17. 横切关注点:RBAC 权限 + +### 17.1 现有实现 + +| 项目 | 状态 | +|------|:---:| +| roles / role_permissions / menus 表 | ✅ | +| permission.json(7 个模块) | ✅ | +| JWT Guard + API Key Guard + Permissions Guard | ✅ | +| DataScope Interceptor(数据范围控制) | ✅ | +| 4 个预置角色 | ✅ | + +### 17.2 权限矩阵(当前) + +| 权限节点 | super_admin | staff | class_teacher | student | +|----------|:---:|:---:|:---:|:---:| +| STUDENT:READ | ✅ | ✅ | ✅ | - | +| STUDENT:ADD/UPDATE | ✅ | ✅ | - | - | +| STUDENT:DELETE | ✅ | ❌ | - | - | +| STUDENT:IMPORT/EXPORT | ✅ | ✅ | ✅ | - | +| DORMITORY:* | ✅ | ✅ | - | - | +| CHECKIN:* | ✅ | ✅ | - | - | +| FEE:READ | ✅ | ✅ | ✅ | - | +| FEE:ADD/UPDATE/DELETE | ✅ | ✅ | - | - | +| BILL:GENERATE | ✅ | ✅ | - | - | +| BILL:CONFIRM/MARK_PAID | ✅ | ✅ | - | - | +| BILL:READ | ✅ | ✅ | ✅ | ✅ | +| BILL:EXPORT | ✅ | ✅ | ✅ | ✅ | +| CLASSROOM:* | ✅ | ✅ | - | - | +| DEPOSIT:COLLECT/REFUND | ✅ | ✅ | - | - | +| DEPOSIT:READ | ✅ | ✅ | ✅ | ✅ | +| ARCHIVE:READ | ✅ | ✅ | ✅ | ✅ | +| ARCHIVE:IMPORT | ✅ | ✅ | - | - | +| ARCHIVE:EXPORT | ✅ | ✅ | - | - | +| REPORT:GENERATE | ✅ | ✅ | - | - | +| OPERATION_LOG:READ | ✅ | ✅ | - | - | +| DASHBOARD:READ | ✅ | ✅ | ✅ | ✅ | + +### 17.3 待补全 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| RBAC.1 | 新增 CLASS:* 权限节点 | P1 | 班级管理模块 | +| RBAC.2 | 新增 SCHEDULE:* 权限节点 | P1 | 排课管理模块 | +| RBAC.3 | 新增 ATTENDANCE:* 权限节点 | P1 | 考勤管理(当前隐含在 ARCHIVE 中) | +| RBAC.4 | 新增 LEARNING:* 权限节点 | P2 | 学情记录管理 | +| RBAC.5 | 新增 EXAM:* 权限节点 | P2 | 考试成绩管理 | +| RBAC.6 | 新增 INTEGRATION:* 权限节点 | P2 | 第三方集成配置 | +| RBAC.7 | staff 角色细化拆分为多角色 | P2 | 财务、宿管、教务分离 | + +--- + +## 18. 横切关注点:操作日志 + +### 18.1 现有实现 + +| 项目 | 状态 | +|------|:---:| +| operation_logs 表 | ✅ | +| OperationLogsService.log() | ✅ | +| 教室租赁模块全操作覆盖 | ✅ | +| 其他模块覆盖 | ❌ | + +### 18.2 操作日志字段 + +``` +operation_logs +├── user_id, username +├── module (模块名) +├── action (动作) +├── target_id, target_type +├── detail (JSON/文本) +├── ip_address, user_agent +├── status (success/failure) +├── created_at +``` + +### 18.3 待覆盖 + +| 模块 | 需审计操作 | +|------|-----------| +| 学生管理 | 新增/编辑/删除学生、导入/导出 | +| 学生档案 | 查看敏感信息(phone/idCard)、生成报告 | +| 账单管理 | 生成账单、确认账单、标记已付、导出 | +| 押金管理 | 收取/退还押金 | +| 费用管理 | 新增/编辑/删除费用 | +| 入住管理 | 新增/编辑/退住 | +| 班级管理 🆕 | 创建/编辑/删除班级、添加/移除学员 | +| 排课管理 🆕 | 创建/编辑/删除排课 | +| 考勤管理 | 补录/编辑考勤、匹配钉钉数据 | +| 权限管理 | 角色变更、权限变更 | + +--- + +## 19. 横切关注点:第三方集成 + +### 19.1 钉钉集成 + +| 能力 | 状态 | +|------|:---:| +| 获取 access_token(带缓存) | ✅ | +| 组织架构拉取(部门+用户) | ✅ | +| 打卡记录 Stream 订阅 | ✅ | +| 工作通知发送 | ✅ | +| 限流控制 | ✅ | + +### 19.2 企业微信集成 + +| 能力 | 状态 | +|------|:---:| +| 获取 access_token(带缓存) | ✅ | +| 组织架构拉取(部门+用户) | ✅ | +| OAuth2 登录 | ✅ | +| 应用消息推送 | ✅ | +| 应用可用性检测 | ✅ | + +### 19.3 待增强 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| INT.1 | 定时自动同步 | P2 | 当前为手动触发,增加 Cron 定时任务 | +| INT.2 | 同步日志/历史 | P2 | 记录每次同步的时间/结果/数量 | +| INT.3 | 增量同步优化 | P2 | 当前全量拉取,优化为增量 | + +--- + +## 20. 横切关注点:报表导出 + +### 20.1 现有实现 + +| 能力 | 状态 | +|------|:---:| +| 学员个人档案 PDF | ✅ | +| 账单 Excel 导出 | ✅ | +| 学员 Excel 导入/导出 | ✅ | +| 学员档案 Excel 模板导入 | ✅ | + +### 20.2 待实现 + +| 编号 | 需求 | 优先级 | 说明 | +|:---:|------|:---:|------| +| RPT.1 | 班级花名册导出 | P1 | 班级学员名单 + 教师信息 Excel | +| RPT.2 | 考勤统计报表 | P1 | 班级/全局出勤率 Excel | +| RPT.3 | 教室使用报表 | P2 | 教室利用率统计 | + +--- + +## 21. 现有代码 vs 目标状态 差距总览 + +| 模块 | 后端模型 | 后端 API | 前端页面 | 操作日志 | 权限定义 | 上线就绪 | +|------|:---:|:---:|:---:|:---:|:---:|:---:| +| 1. 学生管理 | ✅ | ✅ | ✅ | ❌ | ✅ | ⚠️ | +| 2. 学生档案 | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| 3. 班级管理 🆕 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| 4. 排课管理 🆕 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| 5. 教师管理 | ⚠️ | ⚠️ | ❌ | ❌ | ⚠️ | ❌ | +| 6. 教室管理 | ✅ | ✅ | ✅ | ❌ | ✅ | ⚠️ | +| 7. 宿舍管理 | ⚠️ | ⚠️ | ⚠️ | ❌ | ✅ | ❌ | +| 8. 入住管理 | ✅ | ✅ | ✅ | ❌ | ✅ | ⚠️ | +| 9. 费用管理 | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| 10. 账单管理 | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| 11. 押金管理 | ✅ | ✅ | ✅ | ❌ | ✅ | ⚠️ | +| 12. 租赁方管理 | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| 13. 考勤管理 | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| 14. 数据面板 | ⚠️ | ⚠️ | ⚠️ | - | ✅ | ❌ | +| RBAC 权限 | ✅ | ✅ | - | - | ⚠️ | ⚠️ | +| 操作日志 | ✅ | ⚠️ | - | - | - | ❌ | +| 第三方集成 | ✅ | ✅ | ✅ | ❌ | - | ⚠️ | +| 报表导出 | ✅ | ✅ | ✅ | - | - | ⚠️ | + +> ✅ 完成 ⚠️ 部分完成/需增强 ❌ 缺失 `-` 不适用 + +前端tab也需要进行优化,有些tab应该是子项。 + +--- + +## 22. 上线优先级排序 + +### P0 — 阻塞上线,必须完成 + +| 序号 | 模块 | 任务 | 工作量 | +|:---:|------|------|:---:| +| 1 | 班级管理 | 新建 Class / ClassStudent / ClassTeacher 实体 + CRUD API + 前端页面 | **大** | +| 2 | 排课管理 | 新建 ClassSchedule 实体 + 冲突检测 + API + 前端周视图 | **大** | +| 3 | 宿舍管理 | Room 增加 rental_category/monthly_rate;Occupancy 增加 rental_type/tenant_id | **中** | +| 4 | 账单管理 | 长租模式独立账单生成逻辑 | **中** | +| 5 | 操作日志 | 全部关键写操作接入 OperationLogsService | **中** | + +### P1 — 上线前应完成 + +| 序号 | 模块 | 任务 | 工作量 | +|:---:|------|------|:---:| +| 6 | 考勤管理 | 前端考勤管理页面 + 考勤日历 + 班级考勤汇总 | **中** | +| 7 | 数据面板 | 新增教室/考勤/收入指标卡 + 趋势图 | **中** | +| 8 | RBAC | 新增 CLASS/SCHEDULE/ATTENDANCE 权限节点 | **小** | +| 9 | 教室管理 | 占用状态整合排课数据 | **中** | +| 10 | 学生管理 | organization → tenant_id 外键 | **小** | +筛选功能、检索功能,每个涉及大量数据的页面都需要有筛选分类功能,方便我们根据情况快速筛选出需要的信息范围。 + +### P2 — 上线后可迭代 + +| 序号 | 模块 | 任务 | +|:---:|------|------| +| 11 | 报表 | 班级花名册导出、考勤统计报表 | +| 12 | 教师管理 | 教师档案增强、教师工作台 | +| 13 | 学生档案 | 多班型对比视图 | +| 14 | 押金管理 | 分期管理、退还审批流 | +| 15 | 第三方集成 | 定时同步、增量同步 | + +--- + +## 23. 待澄清问题 + +> ⚠️ 以下问题需要你来确认/补充,我标注了待定项。 + +### 23.1 宿舍长租/短租 + +- [ ] 长租的计费方式:是**按月固定费用**独立生成账单,还是也参与某些分摊? +长租和短租是因为我们本身这个线下的基地就是租用别的人的宿舍,有些属于我们长期租的宿舍房间,有的房间属于临时租的短期宿舍房间。 +由于长短涉及到计费不同,所以才需要标记长期短期的问题,本质上是方便我们快速检索相关的宿舍分类,而不只是以楼为单位,宿舍带有的筛选方式有很多。 + +- [ ] 是否存在"混合宿舍"(同一宿舍部分学生长租、部分短租)? +目前不存在,因为长短是指一个房间,不是按人员的租赁时常。这是我们分类的一种方式。 + + +### 23.2 排课管理 + +- [ ] 排课的时间粒度:每天分几个时段?(如 8:00-10:00, 10:00-12:00, 14:00-16:00 ...) +排课时间可以灵活定义全局的,当然我们也有个大致的范围,可以作为预设,但是本身时间都是可调整的。 +时间安排 +7:30-8:40早自习: +9:00-10:30课程学习 +10:30-10:45课间休息 +课程学习10:45-12:00 +12:00-14:00午餐&午休 +14:00-15:30课程学习 +15:30-15:45课间休息 +15:45-17:00课程学习 +17:00-18:30 +晚餐 +18:30-21:00晚自习 +22:30熄灯 + +- [ ] 排课是否需要支持"单双周"模式? +支持常见的排课方式。 + +- [ ] 排课冲突检测:同一教室同一时段完全重叠才算冲突,还是部分重叠也算? +同一教室同一时段完全重叠才算冲突 + +### 23.3 考勤管理 + +- [ ] 考勤时段与排课时段的对应关系?排了课的时间段是否自动生成考勤节点? +要根据学生所处的班级和班型情况,不一定每个学生每天都是满课状态,要按分配的班级和班级绑定的课程时段来看。 + +- [ ] 钉钉打卡数据匹配:完全依赖 resourceUserId 匹配,是否需要手机号兜底匹配? +需要手机号、身份证号都能作为兜底匹配的方案。 + +- [ ] 请假审批流程:系统内提交请假 → 班主任审批 → 自动更新考勤状态?还是线下审批后手动补录? +学生端未来才开放,目前缺勤的话老师会自己看到,目前是手动改,后续支持学生通过学生的前端发起请假。 + +### 23.4 教师角色 + +- [ ] 任课老师/生活老师/学服老师这三种角色是否对应不同的 RBAC 角色?还是都在 class_teacher 下用 class_teacher.role_type 区分? + + +- [ ] 一个教师可否同时承担多个角色(如既是班主任又是某科目的任课老师)? +这个属于灵活配置,完全可以在用户管理里面设置其权限,而不用单独在创建一个自定义的权限,而且默认预设的权限也比较灵活。 + +### 23.5 教室管理 + +- [ ] 教室 status 枚举是否需要扩展?(当前:available/in_use/maintenance,是否需增加 reserved 预留状态?) +- [ ] 教室租赁合同是否需要到期提醒? + +### 23.6 数据面板 + +- [ ] 除了上述指标,还有哪些你特别关注的 KPI 需要在首页看到? +你看下作为一个学生管理系统的使用者,你需要在面板中看到什么信息。 +当然你还需要注意一个问题,就是不同权限的人能看到的面板信息是不一样的,比如自己管自己班的那么看考勤信息应该是自己能看的比较合适的。 +唯有超管才能看到各项指标。 + +### 23.7 其他 + +- [ ] 系统是否需要消息通知中心(站内信)? +需要。 +- [ ] 是否需要多校区切换/隔离?(当前数据层面已在 Department.type=campus 中预留) + + +--- + +> 📝 **请在此文档基础上补充你的需求细节,特别是「23. 待澄清问题」部分。** +> 补充完成后我们进入详细的设计和开发排期阶段。 diff --git a/docs/superpowers/plans/2026-07-06-student-archive.md b/docs/superpowers/plans/2026-07-06-student-archive.md new file mode 100644 index 0000000..607d0ba --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-student-archive.md @@ -0,0 +1,188 @@ +# 学生档案子系统 Implementation Plan + +> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax. + +**Goal:** Rebuild the student profile/report subsystem: 6 new entity tables, CRUD APIs, aggregate query, PDF report generation (pdfkit), frontend profile page, multi-enrollment comparison. + +**Architecture:** New `ArchiveModule` aggregates 6 sub-entities under one API surface. `GET /archive/:studentId` returns the full profile. `GET /archive/:studentId/report` generates PDF. Use pdfkit (already in deps) for PDF; ECharts server-side SVG for charts. + +**Tech Stack:** NestJS 11 + TypeORM + pdfkit + ECharts (SSR via `echarts` npm) + React 19 + Ant Design 6 + +## Global Constraints + ++- All tables have `department_id` for campus scope isolation ++- All write operations log via OperationLogsService ++- PDF uses pdfkit (NOT puppeteer) — already in bill export ++- Charts in PDF rendered as static SVG via echarts SSR ++- Sensitive fields (phone/idNumber) masked in API responses, unmasked in PDF ++- Follow existing NestJS module structure: `archive/` with entity/dto/service/controller ++- Frontend follows existing page patterns (Students page as reference) + +--- + +## Entities Design + +### student_profiles — 扩展档案 +```sql +id (PK), student_id FK UNIQUE, target_college, target_major, subject_direction, +grade, campus_location, profile_date (建档日期), notes, +department_id, created_at, updated_at +``` + +### student_enrollments — 报读记录 +```sql +id (PK), student_id FK, course_category (课程类别), class_type (班型: culture/professional/bootcamp), +class_name, head_teacher, subject_teacher, start_date, end_date, status, +department_id, created_at, updated_at +``` + +### exam_scores — 考试成绩 +```sql +id (PK), student_id FK, enrollment_id FK (nullable, links to enrollment), +exam_type (周测/月测/模考/入学测), exam_name, subject, score (decimal), +class_avg (decimal), rank, exam_date, +department_id, created_at +``` + +### learning_records — 学情记录 +```sql +id (PK), student_id FK, record_date, record_type (课堂表现/作业/沟通/其他), +content (text), follow_up_method, next_step, +department_id, created_at +``` + +### result_archives — 录取归档 +```sql +id (PK), student_id FK, culture_final_score (decimal), professional_final_score (decimal), +admission_status (已录取/未录取/待定), admitted_college, admitted_major, +department_id, created_at, updated_at +``` + +### archive_attachments — 附件 +```sql +id (PK), student_id FK, category (成绩截图/录取截图/协议/其他), +file_name, file_path, file_size, mime_type, +department_id, created_at +``` + +### student_reports — 报告版本 +```sql +id (PK), student_id FK, snapshot_data (JSON — frozen copy of all profile data at generation time), +html_content (text — rendered HTML), pdf_path, generated_at, +department_id +``` + +--- + +### Phase 1: Entities + Module Skeleton + +**Files:** ++- Create: `apps/server/src/entities/student-profile.entity.ts` ++- Create: `apps/server/src/entities/student-enrollment.entity.ts` ++- Create: `apps/server/src/entities/exam-score.entity.ts` ++- Create: `apps/server/src/entities/learning-record.entity.ts` ++- Create: `apps/server/src/entities/result-archive.entity.ts` ++- Create: `apps/server/src/entities/archive-attachment.entity.ts` ++- Create: `apps/server/src/entities/student-report.entity.ts` ++- Modify: `apps/server/src/entities/index.ts` ++- Create: `apps/server/src/archive/archive.module.ts` ++- Create: `apps/server/src/archive/archive.service.ts` ++- Create: `apps/server/src/archive/archive.controller.ts` ++- Create: `apps/server/src/archive/dto/archive.dto.ts` ++- Modify: `apps/server/src/app.module.ts` + +All entities follow existing TypeORM patterns with `@Entity`, `@PrimaryGeneratedColumn`, `@Column`, `@ManyToOne(Student)`, `@CreateDateColumn`. + +ArchiveModule imports `TypeOrmModule.forFeature([all 6 entities])`, is registered in AppModule. + +ArchiveService provides: +- `getProfile(studentId)` — joins all 6 tables, returns aggregate +- `saveProfile(studentId, dto)` — upsert student_profiles +- CRUD for each sub-entity (enrollments, scores, records, results, attachments) +- `generateReport(studentId)` — produces PDF + +--- + +### Phase 2: CRUD APIs + +**ArchiveController endpoints:** + +| Method | Path | Description | +|------|------|------| +| GET | `/archive/:studentId` | Full profile aggregate | +| PUT | `/archive/:studentId/profile` | Upsert student_profiles | +| POST | `/archive/:studentId/enrollments` | Add enrollment | +| PUT | `/archive/enrollments/:id` | Edit enrollment | +| DELETE | `/archive/enrollments/:id` | Delete enrollment | +| POST | `/archive/:studentId/exam-scores` | Add exam score | +| PUT | `/archive/exam-scores/:id` | Edit exam score | +| DELETE | `/archive/exam-scores/:id` | Delete exam score | +| POST | `/archive/:studentId/learning-records` | Add learning record | +| PUT | `/archive/learning-records/:id` | Edit learning record | +| DELETE | `/archive/learning-records/:id` | Delete learning record | +| PUT | `/archive/:studentId/result` | Upsert result archive | +| POST | `/archive/:studentId/attachments` | Upload attachment (multipart) | +| DELETE | `/archive/attachments/:id` | Delete attachment | +| GET | `/archive/:studentId/report` | Generate & download PDF | + +All write endpoints log via OperationLogsService (`module: '学生档案'`). + +--- + +### Phase 3: PDF Report Generation + +Use pdfkit. One file: `apps/server/src/archive/archive-report.service.ts`. + +Report structure (multi-page A4): +1. **封面** — name, studentNo, subjectDirection, targetCollege/targetMajor, headTeacher, profileDate +2. **基础信息** — personal info table + enrollment comparison (culture vs professional side-by-side) +3. **入学测评与阶段概览** — first exam scores, highest scores, improvement (bar chart via echarts SVG) +4. **出勤记录** — attendance summary (pie: present/absent/late/leave), daily matrix +5. **文化课测评** — all culture exam scores table, subject breakdown bar +6. **专业课测评** — all professional exam scores table, learning records list + +The multi-enrollment comparison (PRD 2.2): when student has 2+ enrollments (e.g., culture + professional), each gets its own column in the tables and its own chart section. + +Key implementation: +- `generateReport(studentId)` — orchestrates data gathering, builds PDF sections +- Helper: `renderAttendancePie(records)` → SVG buffer → embedded in PDF +- Helper: `renderScoreBar(scores)` → SVG buffer → embedded in PDF +- Charts: use `echarts` npm package, render to SVG string, convert to buffer, embed via `doc.image()` + +--- + +### Phase 4: Frontend Student Profile Page + +**Files:** ++- Create: `apps/admin/src/pages/StudentProfile/index.tsx` ++- Modify: `apps/admin/src/App.tsx` (add route `/students/:id/profile`) + +Page layout: +- **顶部** — Student info card (name, phone masked, idNumber masked, status, tenant — reusing Students page data) +- **Tabs**: 基础档案 / 报读记录 / 考试成绩 / 学情记录 / 录取结果 / 附件 +- **基础档案 Tab** — Form: 目标院校、目标专业、科类方向、年级、校区、建档日期 +- **报读记录 Tab** — Table + Add modal: 课程类别、班型、班级名、班主任、任课老师、开/结课日期 +- **考试成绩 Tab** — Table + Add/Edit modal: 类型、名称、科目、分数、班级平均、排名、日期 +- **学情记录 Tab** — Table + Add modal: 日期、类型、内容、跟进方式、下一步 +- **录取结果 Tab** — Form: 文化课最终成绩、专业课最终成绩、录取状态、录取院校、录取专业 +- **附件 Tab** — Upload list: 分类、文件名、大小、删除 +- **操作栏** — "生成档案报表" button → downloads PDF + +--- + +### Phase 5: Multi-Enrollment PDF Comparison + +PRD 2.2 specific: when 2+ enrollments exist, the PDF report must show them side-by-side: +- Cover page: list all class_types +- Score tables: columns per enrollment +- Separate chart sections for culture vs professional + +This is handled in the PDF generation logic — the archive-report.service.ts builds sections dynamically based on enrollment count. + +--- + +## Execution Order + +Phase 1→2→4→3→5 (entities→CRUD→frontend→PDF→comparison). Phases 3+5 are combined in the report service. + +**Total: 5 phases, ~12 files created, ~3 files modified.** diff --git a/docs/superpowers/plans/2026-07-06-sync-integration.md b/docs/superpowers/plans/2026-07-06-sync-integration.md new file mode 100644 index 0000000..3fb2c1b --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-sync-integration.md @@ -0,0 +1,611 @@ +# 钉钉/企微同步对接 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `sync.service.ts` stubs with real DingTalk/WeCom API calls — fetch departments and users, persist to DB, record sync logs. + +**Architecture:** Add `source`/`sourceId`/`parentSourceId` to Department entity for idempotent sync matching. Create two integration services (`DingTalkService`, `WeComService`) under `src/integration/`, one shared `IntegrationModule`, wire into `SyncModule`. Each service self-checks env vars and degrades gracefully when not configured. + +**Tech Stack:** NestJS 11 + TypeORM + `@nestjs/schedule` + native `fetch` + +## Global Constraints + ++- MUST check `process.env.DINGTALK_APP_KEY` / `WECOM_CORP_ID` before attempting API calls ++- When env vars missing: log warning, return 0 records, set sync log to `success` (not `failed` — "not configured" is not an error) ++- Sync count returned from `perform*Sync` is the number of **new/updated records persisted** ++- Follow existing NestJS module structure: one directory per concern ++- Each service is independently injectable; `SyncModule` imports `IntegrationModule` ++- Use native `fetch` (Node 18+) — no extra HTTP client dependency ++- Department entity gains nullable `source` / `sourceId` / `parentSourceId` — existing records unaffected ++- Sync preserves existing tree structure: departments matched by `sourceId`, users by `username` + +--- + +### Task 1: Add Source Tracking to Department Entity + +**Files:** ++- Modify: `apps/server/src/entities/department.entity.ts` + +**Interfaces:** ++- Produces: Department entity with nullable `source`, `sourceId`, `parentSourceId` columns + ++- [ ] **Step 1: Add columns to Department entity** + +```typescript +// apps/server/src/entities/department.entity.ts — add after 'status' field (line ~42): + @Column({ length: 20, nullable: true }) + source: string; // 'dingtalk' | 'wecom' | null (null = manual) + + @Column({ name: 'source_id', length: 50, nullable: true }) + sourceId: string; // external dept ID for idempotent sync matching + + @Column({ name: 'parent_source_id', length: 50, nullable: true }) + parentSourceId: string; // external parent dept ID (resolved in post-processing) +``` + ++- [ ] **Step 2: Verify compilation** + +Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -20` +Expected: No new errors from department.entity.ts + ++- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/entities/department.entity.ts +git commit -m "feat: add source tracking fields to Department for sync idempotency" +``` + +--- + +### Task 2: DingTalk Integration Service + +**Files:** ++- Create: `apps/server/src/integration/dingtalk.service.ts` + +**Interfaces:** ++- Produces: `DingTalkService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>` ++- Consumes: `Department` repo, `User` repo + ++- [ ] **Step 1: Create the full DingTalkService** + +```typescript +// apps/server/src/integration/dingtalk.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from '../entities/department.entity'; +import { User } from '../entities/user.entity'; + +interface DingTalkTokenResponse { + errcode: number; + errmsg: string; + access_token: string; + expires_in: number; +} + +interface DingTalkDeptListResponse { + errcode: number; + errmsg: string; + result: Array<{ dept_id: number; name: string; parent_id: number }>; +} + +interface DingTalkUserListResponse { + errcode: number; + errmsg: string; + result: { + has_more: boolean; + list: Array<{ + userid: string; + name: string; + mobile: string; + dept_id_list: number[]; + }>; + }; +} + +@Injectable() +export class DingTalkService { + private readonly logger = new Logger(DingTalkService.name); + private accessToken: string | null = null; + private tokenExpiresAt = 0; + + constructor( + @InjectRepository(Department) + private readonly deptRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + private get configured(): boolean { + return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); + } + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + return this.accessToken; + } + + const appKey = process.env.DINGTALK_APP_KEY!; + const appSecret = process.env.DINGTALK_APP_SECRET!; + const url = `https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`; + const res = await fetch(url); + const body: DingTalkTokenResponse = await res.json(); + + if (body.errcode !== 0) { + throw new Error(`DingTalk gettoken failed: ${body.errmsg} (${body.errcode})`); + } + + this.accessToken = body.access_token; + this.tokenExpiresAt = Date.now() + body.expires_in * 1000; + return this.accessToken; + } + + private async fetchDepartments(token: string): Promise> { + const url = `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dept_id: 1 }), + }); + const body: DingTalkDeptListResponse = await res.json(); + + if (body.errcode !== 0) { + throw new Error(`DingTalk department list failed: ${body.errmsg} (${body.errcode})`); + } + return body.result; + } + + private async fetchUsers( + token: string, + deptId: number, + ): Promise> { + const allUsers: DingTalkUserListResponse['result']['list'] = []; + let cursor = 0; + + while (true) { + const url = `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }), + }); + const body: DingTalkUserListResponse = await res.json(); + + if (body.errcode !== 0) { + throw new Error(`DingTalk user list failed: ${body.errmsg} (${body.errcode})`); + } + + allUsers.push(...body.result.list); + if (!body.result.has_more) break; + cursor = allUsers.length; + } + + return allUsers; + } + + async syncAll(): Promise<{ deptCount: number; userCount: number }> { + if (!this.configured) { + this.logger.warn('DingTalk not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET missing), skipping sync'); + return { deptCount: 0, userCount: 0 }; + } + + const token = await this.getAccessToken(); + const dingDepts = await this.fetchDepartments(token); + + // Upsert departments + let deptCount = 0; + for (const dd of dingDepts) { + const sourceId = String(dd.dept_id); + let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } }); + + if (dept) { + dept.name = dd.name; + dept.parentSourceId = dd.parent_id ? String(dd.parent_id) : null; + } else { + dept = this.deptRepo.create({ + name: dd.name, + source: 'dingtalk', + sourceId, + parentSourceId: dd.parent_id ? String(dd.parent_id) : null, + type: 'department', + }); + deptCount++; + } + await this.deptRepo.save(dept); + } + + // Resolve parentSourceId → parentId for tree linking + const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } }); + const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id])); + for (const dept of syncedDepts) { + if (dept.parentSourceId && idMap.has(dept.parentSourceId)) { + dept.parentId = idMap.get(dept.parentSourceId)!; + } else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') { + dept.parentId = null; // root + } + } + await this.deptRepo.save(syncedDepts); + + // Upsert users across all departments + let userCount = 0; + const seenUserIds = new Set(); + for (const dd of dingDepts) { + const dingUsers = await this.fetchUsers(token, dd.dept_id); + for (const du of dingUsers) { + if (seenUserIds.has(du.userid)) continue; + seenUserIds.add(du.userid); + + let user = await this.userRepo.findOne({ where: { username: du.userid } }); + if (user) { + user.name = du.name; + } else { + user = this.userRepo.create({ + username: du.userid, + name: du.name, + passwordHash: '', + isActive: true, + }); + userCount++; + } + await this.userRepo.save(user); + } + } + + this.logger.log(`DingTalk sync done: ${deptCount} new depts, ${userCount} new users`); + return { deptCount, userCount }; + } +} +``` + ++- [ ] **Step 2: Verify file exists** + +Run: `wc -l apps/server/src/integration/dingtalk.service.ts` +Expected: ~160 lines + +--- + +### Task 3: WeCom Integration Service + +**Files:** ++- Create: `apps/server/src/integration/wecom.service.ts` + +**Interfaces:** ++- Produces: `WeComService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>` + ++- [ ] **Step 1: Create the full WeComService** + +```typescript +// apps/server/src/integration/wecom.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from '../entities/department.entity'; +import { User } from '../entities/user.entity'; + +interface WeComTokenResponse { + errcode: number; + errmsg: string; + access_token: string; + expires_in: number; +} + +interface WeComDeptListResponse { + errcode: number; + errmsg: string; + department: Array<{ id: number; name: string; parentid: number }>; +} + +interface WeComUserListResponse { + errcode: number; + errmsg: string; + userlist: Array<{ + userid: string; + name: string; + mobile: string; + department: number[]; + }>; +} + +@Injectable() +export class WeComService { + private readonly logger = new Logger(WeComService.name); + private accessToken: string | null = null; + private tokenExpiresAt = 0; + + constructor( + @InjectRepository(Department) + private readonly deptRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + private get configured(): boolean { + return !!(process.env.WECOM_CORP_ID && process.env.WECOM_CORP_SECRET); + } + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + return this.accessToken; + } + + const corpId = process.env.WECOM_CORP_ID!; + const corpSecret = process.env.WECOM_CORP_SECRET!; + const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`; + const res = await fetch(url); + const body: WeComTokenResponse = await res.json(); + + if (body.errcode !== 0) { + throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`); + } + + this.accessToken = body.access_token; + this.tokenExpiresAt = Date.now() + body.expires_in * 1000; + return this.accessToken; + } + + private async fetchDepartments( + token: string, + parentId = 1, + ): Promise> { + const all: WeComDeptListResponse['department'] = []; + const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`; + const res = await fetch(url); + const body: WeComDeptListResponse = await res.json(); + + if (body.errcode !== 0) { + // Empty department list is OK for leaf departments + if (body.errcode === 60003) return all; + throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`); + } + + for (const dept of body.department) { + all.push(dept); + if (dept.id !== parentId) { + const children = await this.fetchDepartments(token, dept.id); + all.push(...children); + } + } + + return all; + } + + private async fetchUsers( + token: string, + deptId: number, + ): Promise> { + const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`; + const res = await fetch(url); + const body: WeComUserListResponse = await res.json(); + + if (body.errcode !== 0) { + throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`); + } + + return body.userlist; + } + + async syncAll(): Promise<{ deptCount: number; userCount: number }> { + if (!this.configured) { + this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync'); + return { deptCount: 0, userCount: 0 }; + } + + const token = await this.getAccessToken(); + const wxDepts = await this.fetchDepartments(token); + + // Upsert departments + let deptCount = 0; + for (const wd of wxDepts) { + const sourceId = String(wd.id); + let dept = await this.deptRepo.findOne({ where: { source: 'wecom', sourceId } }); + + if (dept) { + dept.name = wd.name; + dept.parentSourceId = wd.parentid ? String(wd.parentid) : null; + } else { + dept = this.deptRepo.create({ + name: wd.name, + source: 'wecom', + sourceId, + parentSourceId: wd.parentid ? String(wd.parentid) : null, + type: 'department', + }); + deptCount++; + } + await this.deptRepo.save(dept); + } + + // Resolve parentSourceId → parentId + const syncedDepts = await this.deptRepo.find({ where: { source: 'wecom' } }); + const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id])); + for (const dept of syncedDepts) { + if (dept.parentSourceId && idMap.has(dept.parentSourceId)) { + dept.parentId = idMap.get(dept.parentSourceId)!; + } else if (dept.parentSourceId === '0' || dept.parentSourceId === '1') { + dept.parentId = null; + } + } + await this.deptRepo.save(syncedDepts); + + // Upsert users + let userCount = 0; + const seenUserIds = new Set(); + for (const wd of wxDepts) { + const wxUsers = await this.fetchUsers(token, wd.id); + for (const wu of wxUsers) { + if (seenUserIds.has(wu.userid)) continue; + seenUserIds.add(wu.userid); + + let user = await this.userRepo.findOne({ where: { username: wu.userid } }); + if (user) { + user.name = wu.name; + } else { + user = this.userRepo.create({ + username: wu.userid, + name: wu.name, + passwordHash: '', + isActive: true, + }); + userCount++; + } + await this.userRepo.save(user); + } + } + + this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`); + return { deptCount, userCount }; + } +} +``` + ++- [ ] **Step 2: Verify file exists** + +Run: `wc -l apps/server/src/integration/wecom.service.ts` +Expected: ~170 lines + +--- + +### Task 4: Integration Module + Wiring + +**Files:** ++- Create: `apps/server/src/integration/integration.module.ts` ++- Modify: `apps/server/src/sync/sync.module.ts` ++- Modify: `apps/server/src/sync/sync.service.ts` + +**Interfaces:** ++- Consumes: `DingTalkService.syncAll()`, `WeComService.syncAll()` ++- Produces: `SyncService` with real sync calls replacing stubs + ++- [ ] **Step 1: Create IntegrationModule** + +```typescript +// apps/server/src/integration/integration.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Department, User } from '../entities'; +import { DingTalkService } from './dingtalk.service'; +import { WeComService } from './wecom.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Department, User])], + providers: [DingTalkService, WeComService], + exports: [DingTalkService, WeComService], +}) +export class IntegrationModule {} +``` + ++- [ ] **Step 2: Wire IntegrationModule into SyncModule** + +In `apps/server/src/sync/sync.module.ts`: add `IntegrationModule` to the `imports` array and the import statement: + +```typescript +// Add at top: +import { IntegrationModule } from '../integration/integration.module'; + +// In @Module decorator, add IntegrationModule to imports: +@Module({ + imports: [ + ScheduleModule.forRoot(), + TypeOrmModule.forFeature([SyncLog, SyncState]), + IntegrationModule, + ], + // ... rest unchanged +``` + ++- [ ] **Step 3: Inject services and replace stubs in SyncService** + +In `apps/server/src/sync/sync.service.ts`: + +Add imports: +```typescript +import { DingTalkService } from '../integration/dingtalk.service'; +import { WeComService } from '../integration/wecom.service'; +``` + +Add to constructor parameters: +```typescript +constructor( + @InjectRepository(SyncLog) private readonly syncLogRepo: Repository, + @InjectRepository(SyncState) private readonly syncStateRepo: Repository, + private readonly dingTalkService: DingTalkService, + private readonly weComService: WeComService, +) {} +``` + +Replace `performDingTalkSync` (lines 154-166): +```typescript + private async performDingTalkSync(_lastSyncAt: Date | null): Promise { + const result = await this.dingTalkService.syncAll(); + return result.deptCount + result.userCount; + } +``` + +Replace `performWeComSync` (lines 168-179): +```typescript + private async performWeComSync(_lastSyncAt: Date | null): Promise { + const result = await this.weComService.syncAll(); + return result.deptCount + result.userCount; + } +``` + +Also remove the stale JSDoc comments above the old stubs. + ++- [ ] **Step 4: Verify compilation** + +Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -30` +Expected: No type errors from integration/ or sync/ modules + ++- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/integration/ +git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts +git commit -m "feat: wire DingTalk/WeCom integration services into sync pipeline" +``` + +--- + +### Task 5: Verification + +**Files:** ++- _(none modified — verification only)_ + ++- [ ] **Step 1: Start dev server with missing env vars** + +```bash +cd apps/server && npm run start:dev & +sleep 5 +``` + +Check logs: should show `DingTalk not configured... skipping sync` and `WeCom not configured... skipping sync` at startup (or wait for the 2 AM cron, or trigger manually). + ++- [ ] **Step 2: Test manual trigger endpoint** + +```bash +curl -s http://localhost:3002/api/sync/trigger | python3 -m json.tool 2>/dev/null || curl -s http://localhost:3002/api/sync/trigger +``` + +Expected: JSON array of sync log objects with `status: "success"` and `recordsCount: 0` + ++- [ ] **Step 3: Check sync logs endpoint** + +```bash +curl -s http://localhost:3002/api/sync/logs | python3 -m json.tool 2>/dev/null | head -30 +``` + +Expected: Array of sync log entries with fields `platform`, `status`, `recordsCount`, `startedAt`, `finishedAt` + ++- [ ] **Step 4: Verify server still serves other endpoints** + +```bash +curl -s http://localhost:3002/api/students?pageSize=1 | python3 -m json.tool 2>/dev/null | head -10 +``` + +Expected: Normal student list response (no regression) + ++- [ ] **Step 5: Stop server and commit verification** + +```bash +kill %1 2>/dev/null +# If all checks passed, no additional commits needed +``` diff --git a/docs/superpowers/plans/2026-07-06-teacher-management.md b/docs/superpowers/plans/2026-07-06-teacher-management.md new file mode 100644 index 0000000..8ecdfca --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-teacher-management.md @@ -0,0 +1,280 @@ +# 教师管理页 Implementation Plan + +> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax. + +**Goal:** Add admin-facing teacher management: backend teacher list/profile API + frontend Teachers page with list, filter, profile edit. + +**Architecture:** Add `GET /teachers` and `PUT /teachers/:id/profile` to RBAC controller (teachers are RBAC-managed users). Frontend follows existing page pattern (Users page as template). + +**Tech Stack:** NestJS 11 + TypeORM + React 19 + Ant Design 6 + +## Global Constraints + ++- Follow existing patterns: Users page for frontend layout, RBAC controller for teacher endpoints ++- Teacher = any user whose roles include teacher-adjacent roles (code: 'teacher', plus any with class_teacher assignments) ++- Profile field is `simple-json` — edit via a text area or structured form ++- Include class assignments from ClassTeacher join in the list response + +--- + +### Task 1: Backend — Teacher List + Profile API + +**Files:** ++- Modify: `apps/server/src/rbac/rbac.controller.ts` (add endpoints) ++- Modify: `apps/server/src/rbac/rbac.service.ts` (add queries) + +**Interfaces:** ++- Produces: `GET /teachers` → `{ list: TeacherRow[]; total: number }` ++- Produces: `PUT /teachers/:id/profile` → updated User ++- Consumes: User, Role, ClassTeacher repos + ++- [ ] **Step 1: Add getTeachers() to RbacService** + +```typescript +// apps/server/src/rbac/rbac.service.ts — add method + +async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { + const qb = this.userRepo + .createQueryBuilder('u') + .leftJoin('u.roles', 'role') + .leftJoin(ClassTeacher, 'ct', 'ct.userId = u.id') + .leftJoin('ct.class', 'c') + .select([ + 'u.id', 'u.username', 'u.name', 'u.isActive', 'u.profile', 'u.lastLoginAt', + 'role.code', 'role.name', + 'ct.id', 'ct.roleType', 'ct.subject', + 'c.id', 'c.name', + ]) + .where('role.code IN (:...roles)', { roles: ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'] }); + + if (query?.search) { + qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); + } + + const total = await qb.getCount(); + const raw = await qb + .orderBy('u.name', 'ASC') + .skip(((query?.page || 1) - 1) * (query?.pageSize || 20)) + .take(query?.pageSize || 20) + .getMany(); + + // Group class assignments per user + const list = raw.map((u: any) => ({ + id: u.id, + username: u.username, + name: u.name, + isActive: u.isActive, + profile: u.profile, + lastLoginAt: u.lastLoginAt, + roles: (u.roles || []).map((r: any) => ({ code: r.code, name: r.name })), + classAssignments: (u.__ct__ || []).map((ct: any) => ({ + roleType: ct.roleType, + subject: ct.subject, + className: ct.__class__?.name || null, + })), + })); + + return { list, total }; +} +``` + ++- [ ] **Step 2: Add updateTeacherProfile() to RbacService** + +```typescript +async updateTeacherProfile(id: number, profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new NotFoundException('用户不存在'); + user.profile = { ...user.profile, ...profile }; + return this.userRepo.save(user); +} +``` + ++- [ ] **Step 3: Add controller endpoints** + +```typescript +// apps/server/src/rbac/rbac.controller.ts — add endpoints + +@Get('teachers') +@RequirePermission('user:view') +async getTeachers(@Query('search') search?: string, @Query('page') page?: number, @Query('pageSize') pageSize?: number) { + return this.rbacService.getTeachers({ search, page: page ? +page : undefined, pageSize: pageSize ? +pageSize : undefined }); +} + +@Put('teachers/:id/profile') +@RequirePermission('user:edit') +async updateTeacherProfile(@Param('id') id: string, @Body() profile: any, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.rbacService.updateTeacherProfile(+id, profile); + await this.logService.log({ + userId: req.user?.id, username: req.user?.username, + module: '教师管理', action: '编辑档案', + targetId: +id, targetType: 'user', + detail: `更新教师档案`, + ipAddress, userAgent, + }); + return result; +} +``` + ++- [ ] **Step 4: Verify** + +`cd apps/server && npx tsc --noEmit 2>&1 | grep -v spec.ts | grep "error TS" | head -5` +Expected: no errors + +--- + +### Task 2: Frontend — Teachers Management Page + +**Files:** ++- Create: `apps/admin/src/pages/Teachers/index.tsx` + +**Interfaces:** ++- Consumes: `GET /teachers`, `PUT /teachers/:id/profile` ++- Produces: Full page with search, table, profile edit modal + ++- [ ] **Step 1: Create the Teachers page** + +Follow the Users page pattern. Key elements: +- Search bar (name/username) +- Table columns: 姓名, 用户名, 角色(多个Tag), 任课班级(多个Tag), 科目, 入职日期, 状态, 最后登录, 操作 +- Click "编辑档案" → modal with form fields: subjects (Select mode="tags"), joinedAt (DatePicker), qualifications (Input.TextArea) +- Click row → expand to show class assignments detail + +Core structure (abbreviated — implement full component): + +```tsx +import React, { useEffect, useState, useCallback } from 'react'; +import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space, message } from 'antd'; +import { EditOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import api from '../../api'; + +interface TeacherRow { + id: number; + username: string; + name: string; + isActive: boolean; + profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null; + lastLoginAt: string; + roles: { code: string; name: string }[]; + classAssignments: { roleType: string; subject: string; className: string | null }[]; +} + +const ROLE_LABELS: Record = { + super_admin: '超管', teacher: '老师', class_teacher: '班主任', + dormitory_supervisor: '宿管', institution_head: '机构负责人', +}; + +const TeachersPage: React.FC = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [profileModal, setProfileModal] = useState(null); + const [form] = Form.useForm(); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await api.get<{ list: TeacherRow[]; total: number }>('/rbac/teachers', { params: { search: search || undefined, page, pageSize: 20 } }); + setData(res.list); + setTotal(res.total); + } catch { /* silent */ } + setLoading(false); + }, [page, search]); + + useEffect(() => { fetchData(); }, [fetchData]); + + const handleSaveProfile = async () => { + const values = await form.validateFields(); + await api.put(`/rbac/teachers/${profileModal!.id}/profile`, { + subjects: values.subjects || [], + joinedAt: values.joinedAt?.format('YYYY-MM-DD'), + qualifications: values.qualifications, + }); + message.success('已更新'); + setProfileModal(null); + fetchData(); + }; + + const columns = [ + { title: '姓名', dataIndex: 'name', width: 100 }, + { title: '用户名', dataIndex: 'username', width: 120 }, + { + title: '角色', dataIndex: 'roles', width: 200, + render: (roles: TeacherRow['roles']) => roles.map(r => {ROLE_LABELS[r.code] || r.name}), + }, + { + title: '任课班级', dataIndex: 'classAssignments', width: 200, + render: (ca: TeacherRow['classAssignments']) => ca?.length ? ca.map((a, i) => {a.className || '-'}) : '-', + }, + { + title: '科目', dataIndex: 'profile', width: 120, + render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', + }, + { + title: '入职日期', dataIndex: 'profile', width: 110, + render: (p: TeacherRow['profile']) => p?.joinedAt || '-', + }, + { + title: '状态', dataIndex: 'isActive', width: 80, + render: (v: boolean) => {v ? '在职' : '停用'}, + }, + { + title: '最后登录', dataIndex: 'lastLoginAt', width: 160, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-', + }, + { + title: '操作', width: 100, + render: (_: unknown, r: TeacherRow) => ( + + ), + }, + ]; + + return ( +
+

教师管理

+ + + + + setProfileModal(null)}> +
+ +