From 3a2b3f123900fc2b54a2935b1451c1851f69a5ee Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 23 Jul 2026 15:25:20 +0800 Subject: [PATCH] fix: add AI deps, remove lucide-react; chore: remove docs --- docs/PRD.md | 1061 ------- docs/permission-matrix.md | 54 - docs/prd-dingtalk-batch1.md | 534 ---- docs/prd-dingtalk-batch2.md | 295 -- .../plans/2026-07-02-migrate-to-turborepo.md | 735 ----- .../plans/2026-07-02-rbac-refactor.md | 2729 ----------------- .../2026-07-03-admin-responsive-adaptation.md | 1334 -------- .../plans/2026-07-05-gongxue-p0-plan.md | 1802 ----------- .../plans/2026-07-05-multi-campus.md | 1377 --------- .../plans/2026-07-05-notification-center.md | 1273 -------- .../plans/2026-07-06-final-polish.md | 98 - .../plans/2026-07-06-p2-remaining-tasks.md | 586 ---- .../plans/2026-07-06-student-archive.md | 188 -- .../plans/2026-07-06-sync-integration.md | 611 ---- .../plans/2026-07-06-teacher-management.md | 280 -- ...6-07-07-archive-report-frontend-preview.md | 268 -- .../plans/2026-07-09-bed-locker-management.md | 1494 --------- ...7-09-dingtalk-import-class-marking-plan.md | 805 ----- .../plans/2026-07-09-dingtalk-import-fixes.md | 324 -- ...2026-07-09-dingtalk-sync-role-selection.md | 876 ------ .../2026-07-09-org-tree-deepest-levels.md | 174 -- .../plans/2026-07-09-student-ding-mapping.md | 636 ---- .../2026-07-09-student-role-separation.md | 334 -- .../2026-07-10-fix-student-import-name.md | 390 --- .../2026-07-13-binary-course-attendance.md | 76 - ...26-07-13-classroom-field-simplification.md | 225 -- docs/superpowers/plans/casl-migration.md | 282 -- ...-02-add-tsbuildinfo-to-gitignore-verify.md | 26 - .../2026-07-02-migrate-to-turborepo-verify.md | 81 - .../2026-07-02-rbac-refactor-verify.md | 85 - ...7-03-admin-responsive-adaptation-verify.md | 76 - .../2026-07-03-rbac-refactor-verify.md | 104 - .../2026-07-02-migrate-to-turborepo-design.md | 118 - .../specs/2026-07-02-rbac-refactor-design.md | 737 ----- ...7-03-admin-responsive-adaptation-design.md | 163 - .../specs/2026-07-05-gongxue-p0-design.md | 302 -- .../specs/2026-07-05-multi-campus-design.md | 340 -- .../2026-07-05-notification-center-design.md | 233 -- ...6-07-07-archive-report-frontend-preview.md | 79 - .../2026-07-07-prd-gap-closure-design.md | 149 - ...2026-07-09-bed-locker-management-design.md | 256 -- ...09-dingtalk-import-class-marking-design.md | 194 -- ...2026-07-09-dingtalk-import-fixes-design.md | 90 - ...-09-dingtalk-sync-role-selection-design.md | 201 -- ...26-07-09-org-tree-deepest-levels-design.md | 62 - .../2026-07-09-student-ding-mapping-design.md | 56 - ...26-07-09-student-role-separation-design.md | 92 - ...26-07-10-fix-student-import-name-design.md | 112 - ...6-07-13-binary-course-attendance-design.md | 63 - ...3-classroom-field-simplification-design.md | 31 - 50 files changed, 22491 deletions(-) delete mode 100644 docs/PRD.md delete mode 100644 docs/permission-matrix.md delete mode 100644 docs/prd-dingtalk-batch1.md delete mode 100644 docs/prd-dingtalk-batch2.md delete mode 100644 docs/superpowers/plans/2026-07-02-migrate-to-turborepo.md delete mode 100644 docs/superpowers/plans/2026-07-02-rbac-refactor.md delete mode 100644 docs/superpowers/plans/2026-07-03-admin-responsive-adaptation.md delete mode 100644 docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md delete mode 100644 docs/superpowers/plans/2026-07-05-multi-campus.md delete mode 100644 docs/superpowers/plans/2026-07-05-notification-center.md delete mode 100644 docs/superpowers/plans/2026-07-06-final-polish.md delete mode 100644 docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md delete mode 100644 docs/superpowers/plans/2026-07-06-student-archive.md delete mode 100644 docs/superpowers/plans/2026-07-06-sync-integration.md delete mode 100644 docs/superpowers/plans/2026-07-06-teacher-management.md delete mode 100644 docs/superpowers/plans/2026-07-07-archive-report-frontend-preview.md delete mode 100644 docs/superpowers/plans/2026-07-09-bed-locker-management.md delete mode 100644 docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md delete mode 100644 docs/superpowers/plans/2026-07-09-dingtalk-import-fixes.md delete mode 100644 docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md delete mode 100644 docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md delete mode 100644 docs/superpowers/plans/2026-07-09-student-ding-mapping.md delete mode 100644 docs/superpowers/plans/2026-07-09-student-role-separation.md delete mode 100644 docs/superpowers/plans/2026-07-10-fix-student-import-name.md delete mode 100644 docs/superpowers/plans/2026-07-13-binary-course-attendance.md delete mode 100644 docs/superpowers/plans/2026-07-13-classroom-field-simplification.md delete mode 100644 docs/superpowers/plans/casl-migration.md delete mode 100644 docs/superpowers/reports/2026-07-02-add-tsbuildinfo-to-gitignore-verify.md delete mode 100644 docs/superpowers/reports/2026-07-02-migrate-to-turborepo-verify.md delete mode 100644 docs/superpowers/reports/2026-07-02-rbac-refactor-verify.md delete mode 100644 docs/superpowers/reports/2026-07-03-admin-responsive-adaptation-verify.md delete mode 100644 docs/superpowers/reports/2026-07-03-rbac-refactor-verify.md delete mode 100644 docs/superpowers/specs/2026-07-02-migrate-to-turborepo-design.md delete mode 100644 docs/superpowers/specs/2026-07-02-rbac-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-07-03-admin-responsive-adaptation-design.md delete mode 100644 docs/superpowers/specs/2026-07-05-gongxue-p0-design.md delete mode 100644 docs/superpowers/specs/2026-07-05-multi-campus-design.md delete mode 100644 docs/superpowers/specs/2026-07-05-notification-center-design.md delete mode 100644 docs/superpowers/specs/2026-07-07-archive-report-frontend-preview.md delete mode 100644 docs/superpowers/specs/2026-07-07-prd-gap-closure-design.md delete mode 100644 docs/superpowers/specs/2026-07-09-bed-locker-management-design.md delete mode 100644 docs/superpowers/specs/2026-07-09-dingtalk-import-class-marking-design.md delete mode 100644 docs/superpowers/specs/2026-07-09-dingtalk-import-fixes-design.md delete mode 100644 docs/superpowers/specs/2026-07-09-dingtalk-sync-role-selection-design.md delete mode 100644 docs/superpowers/specs/2026-07-09-org-tree-deepest-levels-design.md delete mode 100644 docs/superpowers/specs/2026-07-09-student-ding-mapping-design.md delete mode 100644 docs/superpowers/specs/2026-07-09-student-role-separation-design.md delete mode 100644 docs/superpowers/specs/2026-07-10-fix-student-import-name-design.md delete mode 100644 docs/superpowers/specs/2026-07-13-binary-course-attendance-design.md delete mode 100644 docs/superpowers/specs/2026-07-13-classroom-field-simplification-design.md diff --git a/docs/PRD.md b/docs/PRD.md deleted file mode 100644 index 308eb4f..0000000 --- a/docs/PRD.md +++ /dev/null @@ -1,1061 +0,0 @@ -# 恭学教育学生管理系统 — 产品需求文档 (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/permission-matrix.md b/docs/permission-matrix.md deleted file mode 100644 index f4a0f2f..0000000 --- a/docs/permission-matrix.md +++ /dev/null @@ -1,54 +0,0 @@ -# 权限与默认角色矩阵 - -## 页面入口权限 - -| 页面 | 权限 | -|---|---| -| 数据面板 | `dashboard:view` | -| 宿舍总览 / 宿舍管理 | `room:view` | -| 入住管理 | `occupancy:view` | -| 学生列表 / 学生档案 | `student:view` | -| 班级列表 / 班级详情 / 教师工作台 | `class:view` | -| 考勤管理 | `attendance:view` | -| 排课管理 | `schedule:view` | -| 教室列表 | `classroom:view` | -| 排期总览 | `rental:view` | -| 租赁订单 | `rental:view` | -| 机构管理 | `organization:view` | -| 费用管理 | `expense:view` | -| 押金管理 | `deposit:view` | -| 账单管理 | `bill:view` | -| 通知中心 | `notification:view` | -| 操作日志 | `log:view` | -| 角色管理 / 权限一览 | `role:view` | -| 钉钉集成配置 | `integration:read` | -| AI 模型配置 | `ai:config:read` | -| 账号 / 教师管理 | `user:view` | - -登录后不再固定跳转 Dashboard,而是按上表顺序进入当前账号拥有权限的第一个页面。 - -## 特殊功能与 Tab - -| 功能 | 权限 | -|---|---| -| 押金“待审批退款” Tab | `deposit:approve` | -| 钉钉集成“同步用户” Tab | 同时需要 `sync:read`、`class:view`、`class:edit` | -| 保存钉钉配置 | `integration:trigger` | -| 查看/测试钉钉配置 | `integration:read` | -| 保存 AI 配置 | `ai:config:write` | -| 测试 AI 连接 | `ai:config:test` | -| 清除 AI 密钥 | `ai:config:write` | - -## 默认角色范围 - -| 角色 | 默认范围 | -|---|---| -| 超管 | 全部权限 | -| 宿管老师 | 学生、宿舍、入住、费用、账单、押金、日志、Dashboard、班级、排课、考勤、通知、个人资料 | -| 老师 | 本班学生查看、班级查看、排课查看、考勤查看/录入/导出、通知、个人资料;无 Dashboard | -| 机构负责人 | 教室、租赁、机构、通知、个人资料;无 Dashboard | -| 财务 | 费用、账单、押金、Dashboard、通知、个人资料 | -| 宿管 | 学生、宿舍、入住、押金、Dashboard、通知、个人资料 | -| 教务 | 班级、排课、考勤、教室、学习、考试、Dashboard、通知、个人资料 | - -系统角色的预置权限只会自动补齐,不会删除管理员手动追加的权限。 diff --git a/docs/prd-dingtalk-batch1.md b/docs/prd-dingtalk-batch1.md deleted file mode 100644 index f29b947..0000000 --- a/docs/prd-dingtalk-batch1.md +++ /dev/null @@ -1,534 +0,0 @@ -# PRD 批次1 — 钉钉集成配置持久化(后端) - -> 你是一名 NestJS 后端工程师。本项目是 `gongxue-base`,一个 NestJS + TypeORM 的宿舍管理系统。 -> 你**只做本文件描述的事**,不要改动本文件未提到的任何文件。严格按字段名/列名/路径照抄,不要自己发明命名。 -> 每写完一个文件,回到本文档核对"验收清单"。 - -## 背景(只读,不要改这些文件) - -现状:钉钉配置目前从环境变量读取(`DINGTALK_APP_KEY` / `DINGTALK_APP_SECRET`),没有数据库表、没有配置界面。 -本批次目标:**新增一张配置表 + 一个配置服务 + 一个 config controller**,让钉钉/企微的 corpId、AppKey、AppSecret 可以存进数据库并通过接口读写。本批次**不做**同步逻辑(那是批次2)。 - -已知的项目约定(必须遵守): -1. 全局路由前缀是 `api`(在 `main.ts` 里 `app.setGlobalPrefix('api')`)。所以 controller 里写 `@Controller('integration/config')`,实际路径是 `/api/integration/config`。 -2. 权限用装饰器 `@RequirePermission('code')`,从 `../auth/decorators/permission.decorator.ts` 引入。已存在的权限码:`integration:read`(读配置)、`integration:trigger`(触发同步)。本批次读接口用 `integration:read`,写接口也用 `integration:read`(本项目没有单独的 write 码,先复用)。 -3. 类级别要加 `@UseGuards(JwtAuthGuard)`,从 `../auth/guards/jwt-auth.guard.ts` 引入。 -4. 实体列命名风格:属性名用 camelCase,数据库列名用 snake_case,通过 `@Column({ name: 'snake_case' })` 映射。参考现有实体 `apps/server/src/entities/user-ding-mapping.entity.ts` 的写法。 -5. 时间戳用 `@CreateDateColumn({ name: 'created_at' })` 和 `@UpdateDateColumn({ name: 'updated_at' })`。 -6. 本项目**没有 orgId/多组织概念**。源项目 dorm-sys 有 orgId,你要**去掉所有 orgId 相关字段和参数**。全局只有一份配置。 - ---- - -## 任务清单(4 个新文件 + 1 处修改) - -### 文件 1(新建):`apps/server/src/integration/entities/integration-config.entity.ts` - -新建两个实体。**去掉源项目的 orgId 和 Organization 外键**。完整内容如下(照抄): - -```typescript -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, - ManyToOne, - JoinColumn, -} from 'typeorm'; - -/** - * 第三方集成主配置表。全局单例(本项目无多组织)。 - * type 目前固定为 'THIRD'。 - */ -@Entity('integration_config') -export class IntegrationConfig { - @PrimaryGeneratedColumn() - id: number; - - /** 配置类型,目前固定 'THIRD' */ - @Column({ length: 50 }) - type: string; - - /** 最近一次同步的来源: 'WECOM' | 'DINGTALK' | null */ - @Column({ name: 'sync_resource', length: 50, nullable: true }) - syncResource: string; - - /** 是否已同步过 */ - @Column({ name: 'is_sync', default: false }) - isSync: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} - -/** - * 第三方集成明细配置表。一个主配置对应多条明细(钉钉/企微各一条)。 - * content 存 JSON 字符串,含加密/明文的 corpId、appSecret 等。 - */ -@Entity('integration_config_detail') -@Index(['configId']) -export class IntegrationConfigDetail { - @PrimaryGeneratedColumn() - id: number; - - /** 关联主配置表 IntegrationConfig.id */ - @Column({ name: 'config_id', type: 'integer' }) - configId: number; - - @ManyToOne(() => IntegrationConfig) - @JoinColumn({ name: 'config_id' }) - config: IntegrationConfig; - - @Column({ length: 100, nullable: true }) - name: string; - - /** 明细类型: 'DINGTALK_SYNC' | 'WECOM_SYNC' */ - @Column({ length: 50 }) - type: string; - - /** 配置 JSON 字符串: { type, verify, config: {...} } */ - @Column({ type: 'text', nullable: true }) - content: string; - - /** 该明细是否验证通过(能拿到 token) */ - @Column({ default: false }) - enable: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} -``` - -**验收**:文件存在,导出两个 class `IntegrationConfig`、`IntegrationConfigDetail`,无 orgId 字段,无 import Organization。 - ---- - -### 文件 2(新建):`apps/server/src/integration/config/dto/config.dto.ts` - -配置的类型定义。完整内容(照抄): - -```typescript -/** 钉钉配置 */ -export interface DingTalkThirdConfig { - agentId: string; // AppKey - appSecret: string; // AppSecret - corpId: string; // CorpId - startEnable: boolean; // 是否启用同步 - appId?: string; // 内部应用ID,用于消息推送(可选) -} - -/** 企微配置 */ -export interface WeComThirdConfig { - agentId: string; - appSecret: string; - corpId: string; - startEnable: boolean; -} - -/** 对外返回的配置(脱敏后,不含 appSecret) */ -export interface ThirdConfigBaseDTO { - type: string; - verify?: boolean; - config: T; -} - -/** 保存配置的请求体 */ -export interface SaveConfigRequest { - type: 'WECOM' | 'DINGTALK'; - config: DingTalkThirdConfig | WeComThirdConfig; -} -``` - -**验收**:文件存在,导出 4 个 interface。 - ---- - -### 文件 3(新建):`apps/server/src/integration/config/integration-config.service.ts` - -配置服务。职责:读配置(脱敏)、保存配置(保留旧 appSecret)、测试连接、读写同步状态、给同步逻辑提供原始 appKey/appSecret。 - -**重要:如何测试连接 / 拿 token?** -本项目已有一个 `DingTalkService`(在 `apps/server/src/integration/dingtalk.service.ts`),但它现在从 env 读取密钥、**没有**接受传参的 `getAccessToken(appKey, appSecret)` 方法。为避免改动它(那是批次2的事),**本批次的测试连接直接自己用 fetch 调钉钉接口**,逻辑如下: - -``` -POST https://api.dingtalk.com/v1.0/oauth2/accessToken -body: { "appKey": , "appSecret": } -成功响应含 { accessToken, expireIn } -``` - -企微暂时只做占位(返回 false 即可,本项目重点是钉钉)。 - -完整内容(照抄,仔细核对每个方法): - -```typescript -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { - IntegrationConfig, - IntegrationConfigDetail, -} from '../entities/integration-config.entity'; -import { - ThirdConfigBaseDTO, - DingTalkThirdConfig, - WeComThirdConfig, - SaveConfigRequest, -} from './dto/config.dto'; - -@Injectable() -export class IntegrationConfigService { - private readonly logger = new Logger(IntegrationConfigService.name); - - constructor( - @InjectRepository(IntegrationConfig) - private readonly configRepo: Repository, - @InjectRepository(IntegrationConfigDetail) - private readonly detailRepo: Repository, - ) {} - - /** 获取或创建主配置(全局单例) */ - private async ensureConfig(): Promise { - let config = await this.configRepo.findOne({ where: { type: 'THIRD' } }); - if (!config) { - config = this.configRepo.create({ type: 'THIRD', isSync: false }); - await this.configRepo.save(config); - } - return config; - } - - /** DINGTALK -> DINGTALK_SYNC, WECOM -> WECOM_SYNC */ - private getDetailType(type: string): string { - switch (type.toUpperCase()) { - case 'WECOM': - return 'WECOM_SYNC'; - case 'DINGTALK': - return 'DINGTALK_SYNC'; - default: - throw new BadRequestException(`不支持的第三方类型: ${type}`); - } - } - - /** 获取所有配置(脱敏,不返回 appSecret) */ - async getThirdConfig(): Promise { - const config = await this.ensureConfig(); - const details = await this.detailRepo.find({ where: { configId: config.id } }); - return details.map((detail) => ({ - type: detail.type.includes('WECOM') - ? 'WECOM' - : detail.type.includes('DINGTALK') - ? 'DINGTALK' - : detail.type, - verify: detail.enable, - config: this.parseAndMaskConfig(detail.content), - })); - } - - /** 按类型获取单个配置(脱敏) */ - async getConfigByType(type: string): Promise { - const all = await this.getThirdConfig(); - return all.find((c) => c.type === type.toUpperCase()) || null; - } - - /** 保存/更新配置 */ - async saveConfig(request: SaveConfigRequest): Promise { - const config = await this.ensureConfig(); - const detailType = this.getDetailType(request.type); - - let existingDetail = await this.detailRepo.findOne({ - where: { configId: config.id, type: detailType }, - }); - - const finalConfig = { ...request.config } as Record; - - // 更新时若前端未传 appSecret,则保留旧值 - if (existingDetail && existingDetail.content) { - if (!finalConfig.appSecret) { - try { - const oldParsed = JSON.parse(existingDetail.content); - const oldCfg = oldParsed.config || oldParsed; - if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret; - } catch { - // ignore - } - } - } else if (!finalConfig.appSecret) { - throw new BadRequestException('首次配置必须提供 AppSecret'); - } - - // 连通性验证 - const token = await this.getTokenForTest(request.type, finalConfig); - const verified = !!token; - - const content = JSON.stringify({ - type: request.type, - verify: verified, - config: finalConfig, - }); - - if (existingDetail) { - existingDetail.content = content; - existingDetail.enable = verified; - await this.detailRepo.save(existingDetail); - } else { - existingDetail = this.detailRepo.create({ - configId: config.id, - name: '第三方设置', - type: detailType, - content, - enable: verified, - }); - await this.detailRepo.save(existingDetail); - } - this.logger.log(`第三方配置已保存: ${request.type}, 验证: ${verified}`); - } - - /** 测试连接 */ - async testConnection( - type: string, - config: DingTalkThirdConfig | WeComThirdConfig, - ): Promise { - try { - const token = await this.getTokenForTest(type, config as Record); - return !!token; - } catch (e) { - this.logger.error(`连接测试失败: ${(e as Error).message}`); - return false; - } - } - - /** 读同步状态:某类型是否已同步过 */ - async getSyncStatus(type: string): Promise { - const config = await this.configRepo.findOne({ where: { type: 'THIRD' } }); - if (!config || !config.isSync) return false; - return config.syncResource === type.toUpperCase(); - } - - /** 写同步状态 */ - async setSyncStatus(syncing: boolean, type?: string): Promise { - const config = await this.ensureConfig(); - config.isSync = syncing; - if (type) config.syncResource = type.toUpperCase(); - await this.configRepo.save(config); - } - - /** - * 供同步逻辑使用:读原始(未脱敏)配置。 - * 返回 { agentId, appSecret, corpId, appId? } 或 null。 - */ - async getRawConfig(type: string): Promise | null> { - const config = await this.ensureConfig(); - const detailType = this.getDetailType(type); - const detail = await this.detailRepo.findOne({ - where: { configId: config.id, type: detailType }, - }); - if (!detail || !detail.content) return null; - try { - const parsed = JSON.parse(detail.content); - return parsed.config || parsed; - } catch { - return null; - } - } - - /** - * 供同步逻辑使用:拿一个可用 access_token(未脱敏配置直接用)。 - * 目前仅实现钉钉。 - */ - async getAccessToken(type: string): Promise { - const config = await this.getRawConfig(type); - if (!config) throw new NotFoundException(`未配置 ${type} 平台信息`); - const token = await this.getTokenForTest(type, config); - if (!token) throw new BadRequestException(`获取 ${type} access_token 失败`); - return token; - } - - // ── 私有工具 ── - - /** 用给定配置获取 token(钉钉真实调用,企微暂返回 null) */ - private async getTokenForTest( - type: string, - config: Record, - ): Promise { - try { - if (type.toUpperCase() === 'DINGTALK') { - const appKey = String(config.agentId || ''); - const appSecret = String(config.appSecret || ''); - if (!appKey || !appSecret) return null; - return await this.fetchDingTalkToken(appKey, appSecret); - } - // 企微暂不实现,返回 null - return null; - } catch { - return null; - } - } - - /** 调钉钉新版接口拿 access_token */ - private async fetchDingTalkToken(appKey: string, appSecret: string): Promise { - const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ appKey, appSecret }), - }); - const body = (await res.json()) as { accessToken?: string; expireIn?: number }; - return body.accessToken || null; - } - - /** 解析并脱敏:删掉 appSecret 后返回 config 对象 */ - private parseAndMaskConfig(content: string | null): unknown { - if (!content) return {}; - try { - const parsed = JSON.parse(content); - const cfg = parsed.config || parsed; - if (cfg.appSecret) delete cfg.appSecret; - return cfg; - } catch { - return {}; - } - } -} -``` - -**验收**: -- 导出 `IntegrationConfigService`。 -- 没有任何 orgId 参数。 -- `getThirdConfig` 返回脱敏配置(无 appSecret)。 -- `saveConfig` 更新时若未传 appSecret 会保留旧值。 - ---- - -### 文件 4(新建):`apps/server/src/integration/config/integration-config.controller.ts` - -REST 接口。完整内容(照抄): - -```typescript -import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; -import { RequirePermission } from '../../auth/decorators/permission.decorator'; -import { IntegrationConfigService } from './integration-config.service'; -import { SaveConfigRequest } from './dto/config.dto'; - -@Controller('integration/config') -@UseGuards(JwtAuthGuard) -export class IntegrationConfigController { - constructor(private readonly service: IntegrationConfigService) {} - - /** 获取全部配置(脱敏) */ - @Get() - @RequirePermission('integration:read') - async getConfigs() { - const data = await this.service.getThirdConfig(); - return { success: true, data }; - } - - /** 按类型获取单个配置 */ - @Get(':type') - @RequirePermission('integration:read') - async getConfig(@Param('type') type: string) { - const data = await this.service.getConfigByType(type.toUpperCase()); - if (!data) { - return { success: false, message: `未找到 ${type} 的配置` }; - } - return { success: true, data }; - } - - /** 保存配置 */ - @Post() - @RequirePermission('integration:read') - async saveConfig(@Body() body: SaveConfigRequest) { - await this.service.saveConfig(body); - return { success: true, message: '配置已保存' }; - } - - /** 测试连接 */ - @Post('test') - @RequirePermission('integration:read') - async testConnection(@Body() body: SaveConfigRequest) { - const success = await this.service.testConnection(body.type, body.config); - return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' }; - } -} -``` - -**注意路由顺序陷阱**:`@Get(':type')` 会匹配任意路径段。但 `@Post('test')` 是 POST,`@Get()` 无参,所以本文件没有 GET 冲突。**不要**新增会和 `:type` 冲突的 GET 路由。 - -**验收**:4 个路由,全部 `integration:read` 权限,路径前缀 `integration/config`。 - ---- - -### 文件 5(新建):`apps/server/src/integration/config/config.module.ts` - -模块,注册实体和 provider。完整内容(照抄): - -```typescript -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { - IntegrationConfig, - IntegrationConfigDetail, -} from '../entities/integration-config.entity'; -import { IntegrationConfigService } from './integration-config.service'; -import { IntegrationConfigController } from './integration-config.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([IntegrationConfig, IntegrationConfigDetail])], - controllers: [IntegrationConfigController], - providers: [IntegrationConfigService], - exports: [IntegrationConfigService], -}) -export class IntegrationConfigModule {} -``` - -**验收**:导出 `IntegrationConfigModule`,exports 里有 `IntegrationConfigService`(批次2 要用)。 - ---- - -### 修改 1:`apps/server/src/app.module.ts` - -有 3 处要改,**只加不删**: - -**(a) 注册两个新实体到 `allEntities` 数组。** 找到 `allEntities = [ ... ]` 数组(里面有 `Student, Room, ...UserDingMapping`)。在数组**末尾 `UserDingMapping,` 之后**加两行: - -```typescript - UserDingMapping, - IntegrationConfig, - IntegrationConfigDetail, -``` - -**(b) 在文件顶部 import 这两个实体。** 这两个实体**不在** `./entities` 桶文件里(它们在 integration 目录下),所以单独 import。在其它 import 语句附近(比如 `import { AttendanceModule } ...` 那一片)加一行: - -```typescript -import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity'; -``` - -**(c) 注册模块。** 找到 `@Module({ imports: [ ... ] })` 里的模块列表(有 `AttendanceModule, SchedulesModule, ...`),在合适位置加: - -```typescript - IntegrationConfigModule, -``` - -并在顶部 import: - -```typescript -import { IntegrationConfigModule } from './integration/config/config.module'; -``` - -**验收**:`app.module.ts` 里能看到新增的实体(2 个,在 allEntities 数组里)+ 实体 import + 模块 import + `IntegrationConfigModule` 出现在 imports 数组里。 - ---- - -## 全局验收(做完全部后自检) - -1. 运行 `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json` —— 你新建/修改的文件**不能有任何报错**。(如果看到 `attendance/dto/dingtalk-import.dto.ts` 之类你没碰过的文件报错,忽略,那不是你的。) -2. 不要改动本 PRD 未提到的任何文件。 -3. 不要引入 orgId。 -4. 所有新文件都在 `apps/server/src/integration/` 目录下(entities 子目录、config 子目录)。 - -做完后,用一句话总结你创建/修改了哪些文件。 diff --git a/docs/prd-dingtalk-batch2.md b/docs/prd-dingtalk-batch2.md deleted file mode 100644 index fe426ed..0000000 --- a/docs/prd-dingtalk-batch2.md +++ /dev/null @@ -1,295 +0,0 @@ -# PRD 批次2 — 钉钉「指定节点 BFS 同步」+ 组织树接口(后端) - -> 你是 NestJS 后端工程师,项目 `gongxue-base`。本批次是**改造已有文件**,不是全新建。 -> 严格按下面给的方法体照抄替换。不要改动未提到的方法或文件。 -> 完成后跑 tsc 自检。 - -## 背景 - -现状:`apps/server/src/integration/dingtalk.service.ts` 里的 `DingTalkService.syncAll()` 会从钉钉**根部门(dept_id=1)**开始 BFS 遍历所有部门+用户,写入本地。现在写死从 1 开始。 - -本批次目标: -1. 让 `syncAll` 支持传一个**起始部门 ID**,从该节点开始 BFS(默认仍是 1,保持向后兼容)。 -2. 新增一个方法 `fetchOrgTree()`:只拉钉钉部门(不拉用户),返回**树形结构**,给前端"组织树选择器"用来勾选同步起点。 -3. 新增一个 controller 接口把组织树暴露给前端。 -4. 让现有的 `SyncService.syncDingTalk` / `triggerSync` 能把起始节点透传下去。 - -## 关键事实(现有代码,供你理解,不要改这些无关部分) - -`DingTalkService` 已有这些**私有方法**(保持不动,你会复用它们): -- `private async getAccessToken(): Promise` — 拿 token(从 env 读密钥) -- `private async getAllDeptIds(token: string): Promise` — **当前写死 `queue=[1]`**,你要改它 -- `private async getDeptDetail(token, deptId): Promise<{dept_id, name, parent_id} | null>` -- `private async getDeptUsers(token, deptId): Promise<...>` -- `private async rateLimit()` / `private delay(i)` / `private sleep(ms)` -- `private get configured(): boolean` - -`syncAll()` 现在签名是 `async syncAll(): Promise<{ deptCount: number; userCount: number }>`,内部第一步调用 `const deptIds = await this.getAllDeptIds(token);`。 - ---- - -## 任务 - -### 修改 1:`apps/server/src/integration/dingtalk.service.ts` - -#### (1a) 改造 `getAllDeptIds` —— 支持传起始节点 - -找到现有方法(大致长这样): - -```typescript - private async getAllDeptIds(token: string): Promise { - const ids: number[] = []; - const queue: number[] = [1]; - // ... while 循环 BFS ... - } -``` - -把签名和第一行改成接受可选起始节点,**其余循环体不动**: - -```typescript - private async getAllDeptIds(token: string, rootDeptId = 1): Promise { - const ids: number[] = []; - const queue: number[] = [rootDeptId]; - // ... 下面的 while 循环体保持原样,一个字都不要改 ... - } -``` - -#### (1b) 改造 `syncAll` —— 接受可选起始节点并透传 - -找到 `async syncAll()`,把签名改成: - -```typescript - async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { -``` - -然后在方法体里找到这一行: - -```typescript - const deptIds = await this.getAllDeptIds(token); -``` - -改成: - -```typescript - const deptIds = await this.getAllDeptIds(token, rootDeptId); -``` - -**方法体其它部分(同步部门、同步用户、日志)全部保持原样。** - -#### (1c) 新增方法 `fetchOrgTree` —— 返回部门树给前端 - -在类里新增一个 **public** 方法(放在 `syncAll` 之后即可)。它 BFS 拉所有部门详情,然后组装成树。照抄: - -```typescript - /** - * 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。 - * 返回从指定 rootDeptId 开始的树;默认根部门 1。 - */ - async fetchOrgTree(rootDeptId = 1): Promise { - if (!this.configured) { - throw new Error('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET)'); - } - const token = await this.getAccessToken(); - const deptIds = await this.getAllDeptIds(token, rootDeptId); - - // 拉每个部门详情 - const nodes: DingOrgTreeNode[] = []; - 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) { - nodes.push({ - id: detail.dept_id, - name: detail.name, - parentId: detail.parent_id, - children: [], - }); - } - } - - // 组装成树 - const map = new Map(); - nodes.forEach((n) => map.set(n.id, n)); - const roots: DingOrgTreeNode[] = []; - 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; - } -``` - -#### (1d) 新增类型定义 `DingOrgTreeNode` - -在文件**顶部的类型定义区**(现有那些 `interface DingTalkTokenResponse {...}` 附近)新增并导出: - -```typescript -/** 钉钉部门树节点,供前端选择器使用 */ -export interface DingOrgTreeNode { - id: number; - name: string; - parentId: number; - children: DingOrgTreeNode[]; -} -``` - -**验收 (修改1)**: -- `getAllDeptIds(token, rootDeptId=1)` 第一行是 `const queue: number[] = [rootDeptId];` -- `syncAll(rootDeptId=1)` 内部调用 `getAllDeptIds(token, rootDeptId)` -- 新增 public 方法 `fetchOrgTree(rootDeptId=1)`,返回 `DingOrgTreeNode[]` -- 导出 `DingOrgTreeNode` interface -- 其余原有方法体不变 - ---- - -### 修改 2:`apps/server/src/sync/sync.service.ts` - -现有 `SyncService` 里有: -- `async syncDingTalk(): Promise` — 内部调用 `this.performDingTalkSync(lastSyncAt)` -- `private async performDingTalkSync(lastSyncAt): Promise` — 内部第一行 `const result = await this.dingTalkService.syncAll();` -- `async triggerSync(platform?): Promise` - -我们要让触发同步时能带一个可选起始部门 ID `rootDeptId`。 - -#### (2a) `performDingTalkSync` 接受 rootDeptId - -找到: - -```typescript - private async performDingTalkSync(lastSyncAt: Date | null): Promise { - // Stage 1: Sync departments and users - const result = await this.dingTalkService.syncAll(); -``` - -改成: - -```typescript - private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise { - // Stage 1: Sync departments and users - const result = await this.dingTalkService.syncAll(rootDeptId); -``` - -**该方法其余部分(考勤导入那段)保持原样。** - -#### (2b) `syncDingTalk` 接受并透传 rootDeptId - -找到 `async syncDingTalk(): Promise {`,把签名改成: - -```typescript - async syncDingTalk(rootDeptId = 1): Promise { -``` - -在方法体里找到调用 `performDingTalkSync` 的那行(大概是 `const recordsCount = await this.performDingTalkSync(lastSyncAt);`),改成: - -```typescript - const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId); -``` - -**该方法其余部分(createSyncLog、finishSyncLog、catch 等)保持原样。** - -#### (2c) `triggerSync` 接受 rootDeptId 并透传给钉钉 - -找到: - -```typescript - async triggerSync(platform?: SyncPlatform): Promise { - if (platform === 'dingtalk') return [await this.syncDingTalk()]; - if (platform === 'wecom') return [await this.syncWeCom()]; - return [await this.syncDingTalk(), await this.syncWeCom()]; - } -``` - -改成: - -```typescript - async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise { - if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)]; - if (platform === 'wecom') return [await this.syncWeCom()]; - return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()]; - } -``` - -#### (2d) 新增一个获取组织树的方法 - -在 `SyncService` 类里新增一个 public 方法(放在 `triggerSync` 之后): - -```typescript - /** 获取钉钉组织部门树,供前端选择同步起点 */ - async getDingTalkOrgTree(rootDeptId = 1) { - return this.dingTalkService.fetchOrgTree(rootDeptId); - } -``` - -注意:`SyncService` 构造函数里已经注入了 `private readonly dingTalkService: DingTalkService`,直接用即可,不用改构造函数。 - -**验收 (修改2)**:`triggerSync(platform?, rootDeptId=1)`、`syncDingTalk(rootDeptId=1)`、`performDingTalkSync(lastSyncAt, rootDeptId=1)` 三处签名都改了并透传;新增 `getDingTalkOrgTree(rootDeptId=1)`。 - ---- - -### 修改 3:`apps/server/src/sync/sync.controller.ts` - -现有 controller(`@Controller('sync')`,全局前缀 api,所以是 `/api/sync/...`)有: -- `@Post('trigger')` → `triggerSync(@Query('platform') platform?)` -- `@Get('status')` -- `@Get('logs')` - -#### (3a) 给 trigger 接口加 rootDeptId 查询参数 - -找到: - -```typescript - @Post('trigger') - @RequirePermission('sync:trigger') - async triggerSync(@Query('platform') platform?: SyncPlatform) { - const logs = await this.syncService.triggerSync(platform); - return { synced: logs.length, logs }; - } -``` - -改成(新增 `rootDeptId` 查询参数,字符串转数字): - -```typescript - @Post('trigger') - @RequirePermission('sync:trigger') - async triggerSync( - @Query('platform') platform?: SyncPlatform, - @Query('rootDeptId') rootDeptId?: string, - ) { - const rootId = rootDeptId ? Number(rootDeptId) : 1; - const logs = await this.syncService.triggerSync(platform, rootId); - return { synced: logs.length, logs }; - } -``` - -#### (3b) 新增获取组织树接口 - -在 controller 里新增(放在 `getStatus` 之后): - -```typescript - /** 获取钉钉组织部门树,供前端选择同步起点 */ - @Get('dingtalk/org-tree') - @RequirePermission('sync:read') - async getDingTalkOrgTree(@Query('rootDeptId') rootDeptId?: string) { - const rootId = rootDeptId ? Number(rootDeptId) : 1; - const tree = await this.syncService.getDingTalkOrgTree(rootId); - return { success: true, data: tree }; - } -``` - -**路由顺序检查**:现有 GET 路由是 `status`、`logs`,新增的是 `dingtalk/org-tree`,三者路径不同,无冲突。无需调整顺序。 - -**验收 (修改3)**:`trigger` 接口多了 `rootDeptId` 查询参数;新增 `GET /api/sync/dingtalk/org-tree` 接口,权限 `sync:read`。 - ---- - -## 全局验收(做完自检) - -1. `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json` —— 你改的文件不能有 error。(忽略 `attendance/dto/dingtalk-import.dto.ts` 等你没碰的历史文件。) -2. 只改了 3 个文件:`integration/dingtalk.service.ts`、`sync/sync.service.ts`、`sync/sync.controller.ts`。没动别的。 -3. 所有改动都保持"默认 rootDeptId=1"的向后兼容——不传参时行为和以前完全一样。 - -做完后用一句话总结改了哪些文件、加了哪些方法。 diff --git a/docs/superpowers/plans/2026-07-02-migrate-to-turborepo.md b/docs/superpowers/plans/2026-07-02-migrate-to-turborepo.md deleted file mode 100644 index 58386da..0000000 --- a/docs/superpowers/plans/2026-07-02-migrate-to-turborepo.md +++ /dev/null @@ -1,735 +0,0 @@ ---- -change: migrate-to-turborepo -design-doc: docs/superpowers/specs/2026-07-02-migrate-to-turborepo-design.md -base-ref: 72db78daed6d840f2ddc7c0103f69325253b9098 -archived-with: 2026-07-02-migrate-to-turborepo ---- - -# Turborepo 单体仓库迁移实施计划 - -> **面向自动化执行器:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 按任务逐步实施此计划。任务步骤使用 checkbox (`- [x]`) 语法进行追踪。 - -**目标:** 将 gongxue-base 从松散的多项目结构迁移为 npm workspaces + Turborepo 单体仓库,统一构建编排、工具链和 TypeScript 配置。 - -**架构:** 采用 npm workspaces(`apps/*` + `packages/*`)组织源码,Turborepo 编排构建流水线,oxfmt 统一格式化,oxlint(admin 前端)+ ESLint(server 后端)混合 Lint 策略,`@gongxue/typescript-config` 共享包统一 TypeScript 配置。 - -**技术栈:** Turborepo, npm workspaces, oxfmt, oxlint, ESLint 9, TypeScript ~6.0.2, NestJS, React + Vite, Docker Compose - -## 全局约束 - -- 使用 `git mv` 移动文件,保留完整 Git 历史 -- npm workspaces 模式,不更换包管理器 -- TypeScript 统一版本 `~6.0.2` -- Docker 容器名 `dorm_billing_backend` / `dorm_billing_frontend` 保持不变 -- 所有 workspace 统一使用 `lint` / `format` / `build` / `dev` / `test` 脚本名 -- 每个任务完成后立即 `git commit`,便于独立审查和回滚 - -archived-with: 2026-07-02-migrate-to-turborepo ---- - -### Task 1: 目录重组 - -**文件:** -- 创建: `apps/`(目录) -- 创建: `packages/`(目录) -- 移动: `backend/` → `apps/server/` -- 移动: `frontend/` → `apps/admin/` -- 创建: `packages/typescript-config/package.json` -- 创建: `packages/typescript-config/base.json` -- 创建: `packages/typescript-config/nestjs.json` -- 创建: `packages/typescript-config/react-vite.json` - -**接口:** -- 产出: `apps/server/`(完整 NestJS 项目,路径已更新) -- 产出: `apps/admin/`(完整 Vite + React 项目,路径已更新) -- 产出: `packages/typescript-config/`(三个 TS 预设文件,Task 4 使用) - -- [x] Step 1: 创建顶层目录结构 - -```bash -mkdir -p apps packages -``` - -- [x] Step 2: 使用 git mv 将 backend 移动至 apps/server - -```bash -git mv backend apps/server -``` - -预期结果:无错误,`git status` 显示 `renamed: backend/... -> apps/server/...` - -- [x] Step 3: 使用 git mv 将 frontend 移动至 apps/admin - -```bash -git mv frontend apps/admin -``` - -预期结果:无错误,`git status` 显示 `renamed: frontend/... -> apps/admin/...` - -- [x] Step 4: 创建 packages/typescript-config/package.json - -```json -{ - "name": "@gongxue/typescript-config", - "version": "0.0.0", - "private": true, - "license": "UNLICENSED", - "files": ["base.json", "nestjs.json", "react-vite.json"] -} -``` - -- [x] Step 5: 创建 packages/typescript-config/base.json - -```json -{ - "compilerOptions": { - "target": "ES2023", - "skipLibCheck": true, - "strictNullChecks": true, - "forceConsistentCasingInFileNames": true - } -} -``` - -- [x] Step 6: 创建 packages/typescript-config/nestjs.json - -```json -{ - "extends": "./base.json", - "compilerOptions": { - "module": "nodenext", - "moduleResolution": "nodenext", - "resolvePackageJsonExports": true, - "esModuleInterop": true, - "isolatedModules": true, - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "sourceMap": true, - "incremental": true, - "noImplicitAny": false, - "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false - } -} -``` - -- [x] Step 7: 创建 packages/typescript-config/react-vite.json - -```json -{ - "extends": "./base.json", - "compilerOptions": { - "lib": ["ES2023", "DOM"], - "module": "esnext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - } -} -``` - -- [x] Step 8: 验证目录结构 - -```bash -ls -d apps/server apps/admin packages/typescript-config -``` - -预期输出:三个目录路径均存在。 - -- [x] Step 9: 提交 Task 1 - -```bash -git add apps/ packages/ -git commit -m "feat(task1): restructure directories for turborepo monorepo - -- Move backend/ to apps/server/ via git mv -- Move frontend/ to apps/admin/ via git mv -- Create packages/typescript-config/ with base, nestjs, and react-vite presets" -``` - -archived-with: 2026-07-02-migrate-to-turborepo ---- - -### Task 2: 根配置 - -**文件:** -- 修改: `package.json`(根目录)— 添加 workspaces 和 turbo 脚本 -- 创建: `turbo.json` -- 修改: `.gitignore` — 适配新路径 -- 删除: `node_modules/`、`package-lock.json`(根目录),重新安装 - -**接口:** -- 消费: `apps/server/`、`apps/admin/`、`packages/typescript-config/`(Task 1 产出) -- 产出: 功能正常的 npm workspaces 环境,`turbo run` 可用(Task 3-6 使用) - -- [x] Step 1: 更新根 package.json - -将根 `package.json` 内容替换为: - -```json -{ - "name": "gongxue-base", - "private": true, - "workspaces": [ - "apps/*", - "packages/*" - ], - "scripts": { - "dev": "turbo run dev", - "build": "turbo run build", - "lint": "turbo run lint", - "test": "turbo run test", - "format": "turbo run format", - "typecheck": "turbo run typecheck" - }, - "devDependencies": { - "turbo": "^2.0.0" - }, - "dependencies": { - "@fission-ai/openspec": "^1.5.0" - } -} -``` - -说明:保留 `@fission-ai/openspec` 依赖(根目录 cli 工具使用),新增 `turbo` devDependency,所有根脚本委托 `turbo run`。 - -- [x] Step 2: 创建 turbo.json - -```json -{ - "$schema": "https://turbo.build/schema.json", - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**"] - }, - "dev": { - "cache": false, - "persistent": true - }, - "lint": {}, - "test": {}, - "format": { - "cache": false - }, - "typecheck": { - "dependsOn": ["^build"] - } - } -} -``` - -- [x] Step 3: 更新 .gitignore - -在现有 `.gitignore` 末尾追加以下条目(现有内容保持不变): - -```gitignore -# Turborepo -.turbo/ - -# Monorepo pattern matches -apps/server/node_modules/ -apps/server/dist/ -apps/admin/node_modules/ -apps/admin/dist/ -``` - -- [x] Step 4: 删除旧的 node_modules 和 lock 文件,重新安装 - -```bash -rm -rf node_modules package-lock.json apps/server/node_modules apps/server/package-lock.json apps/admin/node_modules apps/admin/package-lock.json -npm install -``` - -预期结果:安装成功,根目录 `node_modules/` 重新创建,Turbo 被安装。 - -- [x] Step 5: 验证 turbo 可用 - -```bash -npx turbo --version -``` - -预期输出:Turbo 版本号(如 `2.x.x`)。 - -- [x] Step 6: 提交 Task 2 - -```bash -git add package.json turbo.json .gitignore package-lock.json -git commit -m "feat(task2): add root turborepo and npm workspaces configuration - -- Add workspaces field (apps/*, packages/*) -- Delegate all root scripts to turbo run -- Configure turbo.json with build/dev/lint/test/format/typecheck pipelines -- Update .gitignore for turborepo artifacts" -``` - -archived-with: 2026-07-02-migrate-to-turborepo ---- - -### Task 3: 工具链迁移(oxlint + oxfmt) - -**文件:** -- 创建: `.oxfmtrc.json`(根目录) -- 创建: `oxlint.config.ts`(根目录) -- 修改: `apps/admin/package.json` — 替换 lint 脚本,移除 ESLint 依赖 -- 删除: `apps/admin/eslint.config.js` -- 修改: `apps/server/package.json` — 移除 Prettier 相关依赖,保留 ESLint -- 修改: `apps/server/eslint.config.mjs` — 移除 Prettier 集成 -- 删除: `apps/server/.prettierrc` - -**接口:** -- 消费: 根 `package.json`、`turbo.json`(Task 2 产出) -- 产出: oxfmt 统一格式化 + admin oxlint / server ESLint 混合 Lint 策略(Task 6 验证使用) - -- [x] Step 1: 安装 oxfmt - -```bash -npm install --save-dev oxfmt -``` - -- [x] Step 2: 创建根目录 .oxfmtrc.json - -```json -{ - "singleQuote": true, - "trailingComma": "all" -} -``` - -此配置与原来的 `apps/server/.prettierrc` 完全一致。 - -- [x] Step 3: 为 apps/server 添加 format 脚本 - -修改 `apps/server/package.json` 的 `scripts` 字段,添加: - -```json -"format": "oxfmt" -``` - -保持其他脚本不变。 - -- [x] Step 4: 为 apps/admin 添加 format 脚本 - -修改 `apps/admin/package.json` 的 `scripts` 字段,添加: - -```json -"format": "oxfmt" -``` - -- [x] Step 5: 验证 oxfmt 格式化 - -```bash -npx oxfmt --check apps/server/src apps/admin/src -``` - -预期结果:`No formatting issues found`(或报告格式化差异,使用 `npx oxfmt --write` 自动修复)。 - -- [x] Step 6: 安装 oxlint - -```bash -npm install --save-dev oxlint -``` - -- [x] Step 7: 创建根目录 oxlint.config.ts - -```typescript -import type { OxlintConfig } from 'oxlint'; - -const config: OxlintConfig = { - plugins: ['typescript', 'react', 'import'], - rules: { - 'typescript/no-explicit-any': 'off', - 'typescript/no-non-null-assertion': 'warn', - }, - settings: { - react: { - version: 'detect', - }, - }, -}; - -export default config; -``` - -- [x] Step 8: 将 apps/admin 的 lint 脚本迁移为 oxlint - -修改 `apps/admin/package.json` 的 `scripts` 字段,将 `lint` 脚本替换为: - -```json -"lint": "oxlint --config ../../oxlint.config.ts" -``` - -并从 `devDependencies` 中移除以下 ESLint 相关依赖: -- `@eslint/js` -- `eslint` -- `eslint-plugin-react-hooks` -- `eslint-plugin-react-refresh` -- `globals` -- `typescript-eslint` - -- [x] Step 9: 删除 apps/admin 的 ESLint 配置文件 - -```bash -rm apps/admin/eslint.config.js -``` - -- [x] Step 10: 从 apps/server 移除 Prettier 集成 - -修改 `apps/server/eslint.config.mjs`,删除 Prettier 相关行。新内容: - -```javascript -// @ts-check -import eslint from '@eslint/js'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; - -export default tseslint.config( - { - ignores: ['eslint.config.mjs'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - { - languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - sourceType: 'commonjs', - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - { - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', - }, - }, -); -``` - -关键变更: -- 删除 `import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';` -- 删除 `eslintPluginPrettierRecommended` 配置项 -- 删除 `"prettier/prettier": ["error", { endOfLine: "auto" }]` 规则 - -- [x] Step 11: 从 apps/server 移除 Prettier 依赖 - -修改 `apps/server/package.json`,从 `devDependencies` 中移除: -- `eslint-config-prettier` -- `eslint-plugin-prettier` -- `prettier` - -将 `format` 脚本更新为: - -```json -"format": "oxfmt" -``` - -- [x] Step 12: 删除 apps/server 的 .prettierrc - -```bash -rm apps/server/.prettierrc -``` - -格式化配置已统一由根目录 `.oxfmtrc.json` 管理。 - -- [x] Step 13: 验证 lint 工具链 - -```bash -cd apps/server && npx eslint "{src,test}/**/*.ts" && cd ../.. -cd apps/admin && npx oxlint --config ../../oxlint.config.ts && cd ../.. -``` - -预期结果:两个 Lint 检查均通过(或报告现有代码问题,此时应手动修复或将规则降级为 warn)。 - -- [x] Step 14: 提交 Task 3 - -```bash -git add .oxfmtrc.json oxlint.config.ts \ - apps/server/package.json apps/server/eslint.config.mjs \ - apps/admin/package.json -git rm apps/admin/eslint.config.js apps/server/.prettierrc -git commit -m "feat(task3): migrate toolchain to oxfmt + oxlint - -- Install oxfmt with .oxfmtrc.json (maps Prettier config) -- Install oxlint with oxlint.config.ts (TypeScript + React rules) -- Replace admin ESLint with oxlint -- Remove Prettier integration from server ESLint (keep ESLint for type checking) -- Delete server .prettierrc and admin eslint.config.js" -``` - -archived-with: 2026-07-02-migrate-to-turborepo ---- - -### Task 4: TypeScript 配置更新 - -**文件:** -- 修改: `apps/server/tsconfig.json` — 继承 `@gongxue/typescript-config/nestjs.json` -- 修改: `apps/admin/tsconfig.app.json` — 继承 `@gongxue/typescript-config/react-vite.json` -- 修改: `apps/admin/tsconfig.node.json` — 继承 `@gongxue/typescript-config/base.json` -- 修改: `apps/server/package.json` — 更新 name、TypeScript 版本、添加依赖 -- 修改: `apps/admin/package.json` — 更新 name、添加依赖 - -**接口:** -- 消费: `packages/typescript-config/`(Task 1 产出),npm workspaces(Task 2 产出) -- 产出: server 和 admin 均通过 typecheck(Task 6 验证使用) - -- [x] Step 1: 更新 apps/server/package.json — name 和 TypeScript 版本 - -修改 `apps/server/package.json`: -- 将 `"name"` 从 `"backend"` 改为 `"@gongxue/server"` -- 将 `"typescript"` 从 `"^5.7.3"` 改为 `"~6.0.2"` -- 在 `devDependencies` 中添加: - ```json - "@gongxue/typescript-config": "*" - ``` - -- [x] Step 2: 更新 apps/server/tsconfig.json - -```json -{ - "extends": "@gongxue/typescript-config/nestjs.json", - "compilerOptions": { - "outDir": "./dist", - "baseUrl": "./" - } -} -``` - -- [x] Step 3: 更新 apps/admin/package.json — name 和依赖 - -修改 `apps/admin/package.json`: -- 将 `"name"` 从 `"frontend"` 改为 `"@gongxue/admin"` -- 在 `devDependencies` 中添加: - ```json - "@gongxue/typescript-config": "*" - ``` - -- [x] Step 4: 更新 apps/admin/tsconfig.app.json - -```json -{ - "extends": "@gongxue/typescript-config/react-vite.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "types": ["vite/client"] - }, - "include": ["src"] -} -``` - -- [x] Step 5: 更新 apps/admin/tsconfig.node.json - -```json -{ - "extends": "@gongxue/typescript-config/base.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "lib": ["ES2023"], - "module": "esnext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "types": ["node"] - }, - "include": ["vite.config.ts"] -} -``` - -- [x] Step 6: 重新安装依赖 - -```bash -npm install -``` - -预期结果:npm workspaces 解析 `@gongxue/typescript-config: "*"` 工作区内部引用,安装成功。 - -- [x] Step 7: 验证 TypeScript 类型检查 - -```bash -cd apps/server && npx tsc --noEmit && cd ../.. -cd apps/admin && npx tsc -b --noEmit && cd ../.. -``` - -预期结果:两个项目的类型检查均通过。 - -- [x] Step 8: 提交 Task 4 - -```bash -git add apps/server/package.json apps/server/tsconfig.json \ - apps/admin/package.json apps/admin/tsconfig.app.json apps/admin/tsconfig.node.json -git commit -m "feat(task4): update TypeScript configs to use shared presets - -- Server tsconfig extends @gongxue/typescript-config/nestjs.json -- Admin tsconfig.app extends @gongxue/typescript-config/react-vite.json -- Admin tsconfig.node extends @gongxue/typescript-config/base.json -- Update package names: backend -> @gongxue/server, frontend -> @gongxue/admin -- Align server TypeScript to ~6.0.2" -``` - -archived-with: 2026-07-02-migrate-to-turborepo ---- - -### Task 5: Docker 适配 - -**文件:** -- 修改: `docker-compose.yml` — 更新 build.context 路径 - -**接口:** -- 消费: `apps/server/`、`apps/admin/`(Task 1 产出) -- 产出: Docker compose build 和 up 成功运行(Task 6 验证使用) - -- [x] Step 1: 更新 docker-compose.yml 的 build.context - -将 `backend` 服务的 `build:` 从 `./backend` 改为 `./apps/server`: - -```yaml -backend: - build: ./apps/server -``` - -将 `frontend` 服务的 `build:` 从 `./frontend` 改为 `./apps/admin`: - -```yaml -frontend: - build: ./apps/admin -``` - -容器名 `dorm_billing_backend` 和 `dorm_billing_frontend` 保持不变。 - -- [x] Step 2: 验证 Docker Build - -```bash -docker compose build -``` - -预期结果:两个镜像均构建成功,无错误。 - -- [x] Step 3: 提交 Task 5 - -```bash -git add docker-compose.yml -git commit -m "feat(task5): update docker-compose build.context paths - -- backend build.context: ./backend -> ./apps/server -- frontend build.context: ./frontend -> ./apps/admin -- Container names unchanged: dorm_billing_backend, dorm_billing_frontend" -``` - -archived-with: 2026-07-02-migrate-to-turborepo ---- - -### Task 6: 验证 - -**文件:** -- 修改: `apps/server/package.json` — 添加 typecheck 脚本(如需要) -- 修改: `apps/admin/package.json` — 添加 typecheck 脚本(如需要) - -**接口:** -- 消费: Task 1-5 的所有产出 -- 产出: 完整验证报告,确认迁移成功 - -- [x] Step 1: 添加 typecheck 脚本 - -在 `apps/server/package.json` 的 `scripts` 中添加: -```json -"typecheck": "tsc --noEmit" -``` - -在 `apps/admin/package.json` 的 `scripts` 中添加: -```json -"typecheck": "tsc -b --noEmit" -``` - -- [x] Step 2: 验证 npm install - -```bash -npm install -``` - -验证 `@gongxue/typescript-config` 符号链接: -```bash -ls -la apps/server/node_modules/@gongxue/typescript-config -ls -la apps/admin/node_modules/@gongxue/typescript-config -``` - -- [x] Step 3: 验证 npm run build - -```bash -npm run build -``` - -验证构建产物: -```bash -ls apps/server/dist/main.js -ls apps/admin/dist/index.html -``` - -- [x] Step 4: 验证 npm run lint - -```bash -npm run lint -``` - -- [x] Step 5: 验证 npm run format - -```bash -npm run format -``` - -- [x] Step 6: 验证 npm run test - -```bash -npm run test --workspace=apps/server -``` - -- [x] Step 7: 验证 npm run dev - -```bash -npm run dev -``` - -预期:server :3003 + admin :3002 并行启动,API 代理正常。 - -- [x] Step 8: 验证 npm run typecheck - -```bash -npm run typecheck -``` - -- [x] Step 9: 提交 Task 6 - -```bash -git add -A -git commit -m "chore(task6): complete turborepo migration verification - -Verification results: -- [PASS] npm install (all workspaces) -- [PASS] npm run build (server + admin) -- [PASS] npm run lint (server ESLint + admin oxlint) -- [PASS] npm run format (oxfmt) -- [PASS] npm run test (server Jest) -- [PASS] npm run dev (parallel server:3003 + admin:3002, API proxy OK) -- [PASS] npm run typecheck" -``` diff --git a/docs/superpowers/plans/2026-07-02-rbac-refactor.md b/docs/superpowers/plans/2026-07-02-rbac-refactor.md deleted file mode 100644 index db843f9..0000000 --- a/docs/superpowers/plans/2026-07-02-rbac-refactor.md +++ /dev/null @@ -1,2729 +0,0 @@ ---- -change: rbac-refactor -design-doc: docs/superpowers/specs/2026-07-02-rbac-refactor-design.md -base-ref: 78676a124a80542ff89c55cdd6a63af02ac21782 -archived-with: 2026-07-03-rbac-refactor ---- - -# RBAC 鉴权重构 实施计划 - -> **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:** 将项目从简单的 admin/operator 硬编码角色重构为完整的 RBAC(基于角色的访问控制)系统,实现 42 个细粒度权限点、角色-权限关联、后端 PermissionGuard 全局守卫,以及前端权限控制组件。 - -**Architecture:** 认证(AuthModule)与授权(RbacModule)分离。新建 Permission、Role 实体及关联表,User 实体通过 ManyToMany 关联 Role。登录时将 User->Role->Permission 链展开为 `permissions: string[]` 打入 JWT payload,全局 PermissionGuard 读取装饰器声明的权限与 JWT 中的权限做 AND/OR 匹配。前端通过 usePermission hook 从 localStorage 读取权限控制按钮、路由和菜单的可见性。 - -**Tech Stack:** NestJS + TypeORM (SQLite/better-sqlite3, MySQL/mysql2) + Passport JWT + React 19 + Ant Design 6 + React Router 7 + Axios - -## 全局约束 - -- 所有接口默认拒绝访问,除非声明 `@Public()` 或 `@RequirePermission(...)` -- 权限码格式 `module:action`,共 42 个权限点,定义见设计文档 2.3 节 -- 4 个预置角色:超管(super_admin, 全权限)、宿管老师(dormitory_supervisor, 学生/宿舍/入住/费用/账单/押金/日志/dashboard)、老师(teacher, student:view)、机构负责人(institution_head, 教室/租赁 view) -- 多装饰器语义:多个 `@RequirePermission` AND 关系,单个 `@RequirePermission` 内多个参数 OR 关系 -- JWT payload 从 `{ sub, username, role }` 变更为 `{ sub, username, permissions }`,有效期默认 4h -- 权限变更在下次登录生效(JWT 无状态设计) -- 前端权限存储在 localStorage `permissions` key 中,注销时清除 -- Users 表移除 `role`(VARCHAR)和 `allowedMenus`(TEXT)字段,改为关联表 - -archived-with: 2026-07-03-rbac-refactor ---- - -## 文件结构 - -### 新建文件 - -| 文件路径 | 职责 | -|----------|------| -| `backend/src/entities/permission.entity.ts` | Permission 实体定义(id, code, name, group, description) | -| `backend/src/entities/role.entity.ts` | Role 实体定义(id, name, description, isSystem, status, ManyToMany->Permission, ManyToMany->User) | -| `backend/src/rbac/rbac.module.ts` | RbacModule 定义,导入 TypeORM 实体、forwardRef(AuthModule) | -| `backend/src/rbac/rbac.service.ts` | 角色 CRUD、权限查询、getUserPermissions、seedData | -| `backend/src/rbac/rbac.controller.ts` | 角色 CRUD 端点、权限树端点、用户-角色管理端点 | -| `backend/src/rbac/dto/rbac.dto.ts` | CreateRoleDto, UpdateRoleDto, CreateUserDto, UpdateUserDto 等 DTO | -| `backend/src/auth/decorators/public.decorator.ts` | @Public() 装饰器 | -| `backend/src/auth/decorators/permission.decorator.ts` | @RequirePermission(...) 装饰器 | -| `backend/src/auth/guards/permission.guard.ts` | PermissionGuard 全局守卫 | -| `backend/src/data-source.ts` | TypeORM DataSource 配置(供 migration CLI 使用) | -| `frontend/src/hooks/usePermission.ts` | usePermission hook(hasPermission, hasAnyPermission, hasAllPermissions) | -| `frontend/src/components/PermissionButton.tsx` | PermissionButton 组件(无权限时隐藏) | -| `frontend/src/components/PermissionRoute.tsx` | PermissionRoute 组件(无权限时显示 403) | -| `frontend/src/pages/Roles/index.tsx` | 角色管理页面 | -| `frontend/src/pages/Permissions/index.tsx` | 权限一览页面(只读展示) | - -### 修改文件 - -| 文件路径 | 变更内容 | -|----------|----------| -| `backend/src/entities/user.entity.ts` | 移除 role、allowedMenus 字段;新增 roles ManyToMany 关联 | -| `backend/src/entities/index.ts` | 新增 Permission、Role 实体导出 | -| `backend/src/auth/auth.service.ts` | login() 调用 RbacService.getUserPermissions();删除 register()/findAllUsers()/updateUser()/resetPassword()/removeUser()/initAdmin() | -| `backend/src/auth/auth.module.ts` | imports 增加 forwardRef(() => RbacModule);移除 OnModuleInit | -| `backend/src/auth/auth.controller.ts` | 删除 /register、/users/* 端点;login 添加 @Public() | -| `backend/src/auth/strategies/jwt.strategy.ts` | validate() 返回 permissions 替换 role | -| `backend/src/app.module.ts` | imports 增加 RbacModule;providers 增加 PermissionGuard 为 APP_GUARD;entities 列表增加 Permission、Role | -| `backend/src/students/students.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/rooms/rooms.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/occupancies/occupancies.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/expenses/expenses.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/bills/bills.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/deposits/deposits.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/classrooms/classrooms.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/tenants/tenants.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/classroom-rentals/classroom-rentals.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/dashboard/dashboard.controller.ts` | 添加 @RequirePermission 装饰器 | -| `backend/src/operation-logs/operation-logs.controller.ts` | 添加 @RequirePermission 装饰器 | -| `frontend/src/App.tsx` | 新增 Roles/Permissions 路由;PrivateRoute 集成 PermissionRoute | -| `frontend/src/layouts/MainLayout.tsx` | 菜单过滤改为基于 permissions 数组;新增角色/权限菜单项 | -| `frontend/src/api/index.ts` | 403 响应拦截器(显示"权限不足"提示,不跳登录) | -| `frontend/src/pages/Login/index.tsx` | 登录成功存储 permissions 到 localStorage | -| `frontend/src/pages/Users/index.tsx` | 重构:角色列显示多角色 Tag;编辑弹窗使用 Select multiple;移除 allowedMenus 相关代码;API 端点改为 /rbac/users | - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 1: 数据库实体与种子数据 - -**Files:** -- Create: `backend/src/entities/permission.entity.ts` -- Create: `backend/src/entities/role.entity.ts` -- Modify: `backend/src/entities/user.entity.ts` -- Modify: `backend/src/entities/index.ts` - -**Interfaces:** -- Produces: `Permission { id, code, name, group, description }`, `Role { id, name, description, isSystem, status, createdAt, updatedAt, permissions: Permission[], users: User[] }`, `User { ..., roles: Role[] }` (role 和 allowedMenus 字段移除) - -- [x] **Step 1: 创建 Permission 实体** - -```typescript -// backend/src/entities/permission.entity.ts -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; - -@Entity('permissions') -export class Permission { - @PrimaryGeneratedColumn() - id: number; - - @Column({ type: 'varchar', length: 50, unique: true }) - code: string; - - @Column({ type: 'varchar', length: 50 }) - name: string; - - @Column({ type: 'varchar', length: 30 }) - group: string; - - @Column({ type: 'varchar', length: 200, nullable: true }) - description: string; -} -``` - -- [x] **Step 2: 创建 Role 实体** - -```typescript -// backend/src/entities/role.entity.ts -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable } from 'typeorm'; -import { Permission } from './permission.entity'; -import { User } from './user.entity'; - -@Entity('roles') -export class Role { - @PrimaryGeneratedColumn() - id: number; - - @Column({ type: 'varchar', length: 30, unique: true }) - name: string; - - @Column({ type: 'varchar', length: 200, nullable: true }) - description: string; - - @Column({ name: 'is_system', type: 'boolean', default: false }) - isSystem: boolean; - - @Column({ type: 'tinyint', default: 1 }) - status: number; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @ManyToMany(() => Permission) - @JoinTable({ - name: 'role_permissions', - joinColumn: { name: 'role_id', referencedColumnName: 'id' }, - inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }, - }) - permissions: Permission[]; - - @ManyToMany(() => User, (user) => user.roles) - users: User[]; -} -``` - -- [x] **Step 3: 修改 User 实体** - -In `backend/src/entities/user.entity.ts`,移除 `role` 列定义和 `allowedMenus` 列定义,新增 `roles` 多对多关联: - -```typescript -// backend/src/entities/user.entity.ts -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable } from 'typeorm'; -import { Role } from './role.entity'; - -@Entity('users') -export class User { - @PrimaryGeneratedColumn() - id: number; - - @Column({ length: 50, unique: true }) - username: string; - - @Column({ name: 'password_hash', length: 255 }) - passwordHash: string; - - // 移除: @Column({ type: 'varchar', length: 20, default: 'operator' }) - // role: string; - - @Column({ length: 50, nullable: true }) - name: string; - - // 移除: @Column({ name: 'allowed_menus', type: 'text', nullable: true }) - // allowedMenus: string; - - @Column({ name: 'is_active', default: true }) - isActive: boolean; - - @Column({ name: 'last_login_at', type: 'datetime', nullable: true }) - lastLoginAt: Date; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @ManyToMany(() => Role, (role) => role.users) - @JoinTable({ - name: 'user_roles', - joinColumn: { name: 'user_id', referencedColumnName: 'id' }, - inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' }, - }) - roles: Role[]; -} -``` - -- [x] **Step 4: 更新 entities/index.ts 导出** - -```typescript -// backend/src/entities/index.ts -export { Student } from './student.entity'; -export { Room } from './room.entity'; -export { Occupancy } from './occupancy.entity'; -export { RoomExpense } from './room-expense.entity'; -export { PersonalExpense } from './personal-expense.entity'; -export { Bill } from './bill.entity'; -export { BillItem } from './bill-item.entity'; -export { User } from './user.entity'; -export { OperationLog } from './operation-log.entity'; -export { Deposit } from './deposit.entity'; -export { Classroom } from './classroom.entity'; -export { Tenant } from './tenant.entity'; -export { ClassroomRental } from './classroom-rental.entity'; -export { Permission } from './permission.entity'; -export { Role } from './role.entity'; -``` - -- [x] **Step 5: 编译验证实体定义** (实体层通过;auth.service.ts 错误待 Task 4 修复) - -```bash -cd backend && npx tsc --noEmit -``` - -预期:无类型错误,ManyToMany 关系装饰器正确。 - -- [x] **Step 6: Commit** - -```bash -git add backend/src/entities/permission.entity.ts backend/src/entities/role.entity.ts backend/src/entities/user.entity.ts backend/src/entities/index.ts -git commit -m "feat(rbac): add Permission and Role entities, update User entity" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 2: RBAC 服务层 (RbacModule + RbacService) - -**Files:** -- Create: `backend/src/rbac/rbac.module.ts` -- Create: `backend/src/rbac/rbac.service.ts` - -**Interfaces:** -- Consumes: `Permission`, `Role`, `User` 实体(来自 Task 1) -- Produces: `RbacService.getUserPermissions(userId: number): Promise`, `RbacService.findAllRoles()`, `RbacService.createRole(dto)`, `RbacService.updateRole(id, dto)`, `RbacService.deleteRole(id)`, `RbacService.getPermissionTree()`, `RbacService.seedData()` (幂等种子数据) - -**依赖**: 先用 `forwardRef` 占位 AuthModule 引用,Task 3 解决循环依赖。 - -- [x] **Step 1: 创建 RbacModule** - -```typescript -// backend/src/rbac/rbac.module.ts -import { Module, OnModuleInit, forwardRef } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Permission, Role, User } from '../entities'; -import { RbacService } from './rbac.service'; -import { RbacController } from './rbac.controller'; -import { AuthModule } from '../auth/auth.module'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([Permission, Role, User]), - forwardRef(() => AuthModule), - ], - controllers: [RbacController], - providers: [RbacService], - exports: [RbacService], -}) -export class RbacModule implements OnModuleInit { - constructor(private rbacService: RbacService) {} - async onModuleInit() { - await this.rbacService.seedData(); - } -} -``` - -- [x] **Step 2: 创建 RbacService — 种子数据方法** - -在 `backend/src/rbac/rbac.service.ts` 中,先定义权限点常量和预设角色,然后实现 `seedData()`: - -```typescript -// backend/src/rbac/rbac.service.ts -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Permission, Role, User } from '../entities'; - -const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ - { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, - { code: 'student:view', name: '查看学生', group: 'student' }, - { code: 'student:create', name: '新增学生', group: 'student' }, - { code: 'student:edit', name: '编辑学生', group: 'student' }, - { code: 'student:delete', name: '删除学生', group: 'student' }, - { code: 'student:import', name: '导入学生', group: 'student' }, - { code: 'student:export', name: '导出学生', group: 'student' }, - { code: 'room:view', name: '查看宿舍', group: 'room' }, - { code: 'room:create', name: '新增宿舍', group: 'room' }, - { code: 'room:edit', name: '编辑宿舍', group: 'room' }, - { code: 'room:delete', name: '删除宿舍', group: 'room' }, - { code: 'occupancy:view', name: '查看入住', group: 'occupancy' }, - { code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' }, - { code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' }, - { code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' }, - { code: 'expense:view', name: '查看费用', group: 'expense' }, - { code: 'expense:create', name: '录入费用', group: 'expense' }, - { code: 'expense:edit', name: '编辑费用', group: 'expense' }, - { code: 'expense:delete', name: '删除费用', group: 'expense' }, - { code: 'bill:view', name: '查看账单', group: 'bill' }, - { code: 'bill:generate', name: '生成账单', group: 'bill' }, - { code: 'bill:confirm', name: '确认账单', group: 'bill' }, - { code: 'bill:delete', name: '删除账单', group: 'bill' }, - { code: 'bill:export-excel', name: '导出 Excel', group: 'bill' }, - { code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' }, - { code: 'deposit:view', name: '查看押金', group: 'deposit' }, - { code: 'deposit:create', name: '新增押金', group: 'deposit' }, - { code: 'deposit:edit', name: '编辑押金', group: 'deposit' }, - { code: 'deposit:delete', name: '删除押金', group: 'deposit' }, - { code: 'classroom:view', name: '查看教室', group: 'classroom' }, - { code: 'classroom:create', name: '新增教室', group: 'classroom' }, - { code: 'classroom:edit', name: '编辑教室', group: 'classroom' }, - { code: 'classroom:delete', name: '删除教室', group: 'classroom' }, - { code: 'tenant:view', name: '查看租赁方', group: 'tenant' }, - { code: 'tenant:create', name: '新增租赁方', group: 'tenant' }, - { code: 'tenant:edit', name: '编辑租赁方', group: 'tenant' }, - { code: 'tenant:delete', name: '删除租赁方', group: 'tenant' }, - { code: 'rental:view', name: '查看租赁订单', group: 'rental' }, - { code: 'rental:create', name: '新增租赁订单', group: 'rental' }, - { code: 'rental:edit', name: '编辑租赁订单', group: 'rental' }, - { code: 'rental:delete', name: '删除租赁订单', group: 'rental' }, - { code: 'log:view', name: '查看操作日志', group: 'log' }, - { code: 'user:view', name: '查看用户', group: 'user' }, - { code: 'user:create', name: '创建用户', group: 'user' }, - { code: 'user:edit', name: '编辑用户', group: 'user' }, - { code: 'user:delete', name: '删除用户', group: 'user' }, - { code: 'user:reset-password', name: '重置密码', group: 'user' }, - { code: 'role:view', name: '查看角色', group: 'role' }, - { code: 'role:create', name: '创建角色', group: 'role' }, - { code: 'role:edit', name: '编辑角色', group: 'role' }, - { code: 'role:delete', name: '删除角色', group: 'role' }, -]; - -const PRESET_ROLES: Array<{ name: string; code: string; description: string; isSystem: boolean; permissionGroups: string[]; extraPermissions?: string[] }> = [ - { name: '超管', code: 'super_admin', description: '系统超级管理员,拥有全部权限', isSystem: true, permissionGroups: [] }, - { name: '宿管老师', code: 'dormitory_supervisor', description: '管理宿舍相关业务', isSystem: true, permissionGroups: ['student', 'room', 'occupancy', 'expense', 'bill', 'deposit', 'log', 'dashboard'] }, - { name: '老师', code: 'teacher', description: '查看和管理本班学生', isSystem: true, permissionGroups: ['student'], extraPermissions: ['student:view'] }, - { name: '机构负责人', code: 'institution_head', description: '管理机构教室和课程', isSystem: true, permissionGroups: ['classroom', 'rental', 'tenant'] }, -]; - -@Injectable() -export class RbacService { - private readonly logger = new Logger(RbacService.name); - - constructor( - @InjectRepository(Permission) private permRepo: Repository, - @InjectRepository(Role) private roleRepo: Repository, - @InjectRepository(User) private userRepo: Repository, - ) {} -``` - -续 RbacService: - -```typescript - async seedData(): Promise { - // Step 1: 幂等插入所有权限点 - for (const p of PRESET_PERMISSIONS) { - await this.permRepo - .createQueryBuilder() - .insert() - .into(Permission) - .values(p) - .orIgnore() - .execute(); - } - const allPerms = await this.permRepo.find(); - - // Step 2: 幂等插入预置角色 - for (const r of PRESET_ROLES) { - await this.roleRepo - .createQueryBuilder() - .insert() - .into(Role) - .values({ name: r.name, description: r.description, isSystem: r.isSystem }) - .orIgnore() - .execute(); - } - const allRoles = await this.roleRepo.find({ relations: ['permissions'] }); - - // Step 3: 构建角色-权限关联 - for (const preset of PRESET_ROLES) { - const role = allRoles.find(r => r.name === preset.name); - if (!role) continue; - - let perms: Permission[]; - if (preset.permissionGroups.length === 0) { - // 超管:全部权限 - perms = allPerms; - } else { - // 按 group 匹配 + 额外权限(如老师的 student:view) - const byGroup = allPerms.filter(p => preset.permissionGroups.includes(p.group)); - const byExtra = preset.extraPermissions - ? allPerms.filter(p => preset.extraPermissions!.includes(p.code)) - : []; - perms = [...byGroup, ...byExtra].filter( - (p, i, arr) => arr.findIndex(x => x.id === p.id) === i - ); - } - - // 幂等:只插入尚未关联的 - const existingIds = new Set(role.permissions.map(p => p.id)); - const toAdd = perms.filter(p => !existingIds.has(p.id)); - if (toAdd.length > 0) { - role.permissions = [...role.permissions, ...toAdd]; - await this.roleRepo.save(role); - } - } - - // Step 4: 初始化 admin 用户(复用原 initAdmin 逻辑) - const count = await this.userRepo.count(); - if (count === 0) { - const bcrypt = require('bcryptjs'); - const { ConfigService } = require('@nestjs/config'); - // 由于不能直接注入 ConfigService,使用环境变量 fallback - const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; - const hash = await bcrypt.hash(adminPassword, 10); - const adminUser = this.userRepo.create({ username: 'admin', passwordHash: hash, name: '管理员' }); - const superAdminRole = allRoles.find(r => r.name === '超管'); - if (superAdminRole) { - adminUser.roles = [superAdminRole]; - } - await this.userRepo.save(adminUser); - this.logger.log( - `已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`, - ); - } - - this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); - } -``` - -- [x] **Step 3: 实现角色 CRUD 方法** - -在同一个 `RbacService` 中追加: - -```typescript - async findAllRoles(): Promise { - return this.roleRepo.find({ - relations: ['permissions'], - order: { id: 'ASC' }, - }); - } - - async findRoleById(id: number): Promise { - return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); - } - - async createRole(dto: { name: string; description?: string; permissionIds?: number[] }): Promise { - const role = this.roleRepo.create({ name: dto.name, description: dto.description }); - if (dto.permissionIds && dto.permissionIds.length > 0) { - role.permissions = await this.permRepo.findByIds(dto.permissionIds); - } - return this.roleRepo.save(role); - } - - async updateRole(id: number, dto: { name?: string; description?: string; permissionIds?: number[] }): Promise { - const role = await this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); - if (dto.name !== undefined && !role.isSystem) role.name = dto.name; - if (dto.description !== undefined) role.description = dto.description; - if (dto.permissionIds !== undefined) { - role.permissions = dto.permissionIds.length > 0 - ? await this.permRepo.findByIds(dto.permissionIds) - : []; - } - return this.roleRepo.save(role); - } - - async deleteRole(id: number): Promise<{ message: string }> { - const role = await this.roleRepo.findOneOrFail({ where: { id } }); - if (role.isSystem) throw new Error('系统角色不可删除'); - await this.roleRepo.remove(role); - return { message: '角色已删除' }; - } -``` - -- [x] **Step 4: 实现权限查询方法** - -```typescript - async findAllPermissions(): Promise { - return this.permRepo.find({ order: { group: 'ASC', code: 'ASC' } }); - } - - async getPermissionTree(): Promise<{ group: string; permissions: Permission[] }[]> { - const all = await this.findAllPermissions(); - const map = new Map(); - for (const p of all) { - if (!map.has(p.group)) map.set(p.group, []); - map.get(p.group)!.push(p); - } - return Array.from(map.entries()).map(([group, permissions]) => ({ group, permissions })); - } - - async getUserPermissions(userId: number): Promise { - const user = await this.userRepo.findOne({ - where: { id: userId }, - relations: ['roles', 'roles.permissions'], - }); - if (!user || !user.roles) return []; - const codes = new Set(); - for (const role of user.roles) { - if (role.status !== 1) continue; - for (const perm of role.permissions) { - codes.add(perm.code); - } - } - return Array.from(codes); - } -``` - -- [x] **Step 5: 编译验证** - -```bash -cd backend && npx tsc --noEmit -``` - -预期:无类型错误。 - -- [x] **Step 6: Commit** - -```bash -git add backend/src/rbac/ -git commit -m "feat(rbac): add RbacModule and RbacService with seed data and role CRUD" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 3: 权限守卫装饰器 (PermissionGuard + @Public + @RequirePermission) - -**Files:** -- Create: `backend/src/auth/decorators/public.decorator.ts` -- Create: `backend/src/auth/decorators/permission.decorator.ts` -- Create: `backend/src/auth/guards/permission.guard.ts` - -**Interfaces:** -- Consumes: `ExecutionContex`, `Reflector` (NestJS Core) -- Produces: `@Public()` (SetMetadata IS_PUBLIC_KEY true), `@RequirePermission(...codes: string[])` (SetMetadata PERMISSION_KEY), `PermissionGuard implements CanActivate` - -- [x] **Step 1: 创建 @Public 装饰器** - -```typescript -// backend/src/auth/decorators/public.decorator.ts -import { SetMetadata } from '@nestjs/common'; - -export const IS_PUBLIC_KEY = 'isPublic'; -export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); -``` - -- [x] **Step 2: 创建 @RequirePermission 装饰器** - -```typescript -// backend/src/auth/decorators/permission.decorator.ts -import { SetMetadata } from '@nestjs/common'; - -export const PERMISSION_KEY = 'permissions'; - -/** - * 声明接口所需权限。 - * 多次调用 = AND 逻辑(需要同时满足所有组); - * 单次调用多个参数 = OR 逻辑(满足其中一个即可)。 - */ -export const RequirePermission = (...permissions: string[]) => - SetMetadata(PERMISSION_KEY, permissions); -``` - -- [x] **Step 3: 创建 PermissionGuard** - -```typescript -// backend/src/auth/guards/permission.guard.ts -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; -import { PERMISSION_KEY } from '../decorators/permission.decorator'; - -@Injectable() -export class PermissionGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - // 1. @Public() 豁免 - const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (isPublic) return true; - - // 2. 获取所需权限(多装饰器合并为 string[][]) - const requiredPermissions = this.reflector.getAllAndOverride( - PERMISSION_KEY, - [context.getHandler(), context.getClass()], - ); - // 无装饰器 = 默认拒绝 - if (!requiredPermissions || requiredPermissions.length === 0) return false; - - // 3. 从 JWT payload 获取用户权限 - const request = context.switchToHttp().getRequest(); - const user = request.user; - if (!user || !user.permissions || !Array.isArray(user.permissions)) return false; - - // 4. 匹配逻辑 - // requiredPermissions 结构: - // - 单个 @RequirePermission('a', 'b') → [['a', 'b']] → anyMatch → OR - // - 多个 @RequirePermission('a') @RequirePermission('b') → [['a'], ['b']] → every → AND - // 但实际上 NestJS 的 getAllAndOverride 会将多个 SetMetadata 合并为一个数组。 - // 对于 @RequirePermission('a', 'b'),Reflector 返回 ['a', 'b'](单个调用)。 - // 对于多次调用,Reflector 会将每次调用的数组放入外层数组。 - // 借助 Reflector.getAllAndMerge 或利用 getAllAndOverride 的行为: - // - // 实际验证:getAllAndOverride 在多个装饰器时返回最后一个的值,不是合并! - // 所以需要改用 getAllAndMerge。 - // - // 纠正: - const rawPerms = this.reflector.get(PERMISSION_KEY, context.getHandler()); - // 如果没有 handler 级别的,则取 class 级别的 - const allRequired: string[][] = []; - const handlerPerms = this.reflector.get(PERMISSION_KEY, context.getHandler()); - const classPerms = this.reflector.get(PERMISSION_KEY, context.getClass()); - - // 每个 @RequirePermission(...) 调用会给 metadata 数组推入一项 - // Reflector.get 返回最后一次 SetMetadata 的值(覆盖语义)。 - // 正确做法:使用 Reflector.get 结合 NestJS 的 metadata 合并机制。 - // - // 改用更可靠的方案:直接从 handler 和 class 的原始 metadata 读取: - // Reflect.getMetadata 可以拿到所有装饰器的值。 - // - // 实际上 NestJS 的 SetMetadata 多次调用确实会覆盖。对于 AND 语义, - // 最佳实践是传递字符串数组并使用自定义匹配器。 - // 这里简化:单个装饰器传入权限数组,使用 includes 匹配。 - // - // 最终简化方案: - // @RequirePermission('a', 'b') → metadata = ['a', 'b'] → OR (任一即可) - // 暂不支持 AND,如需 AND 用 @RequirePermission('a:view') 然后 Controller - // 层叠加两次调用,通过 getAllAndMerge 获取。 - } -} -``` - -停一下。我需要对 NestJS 的 `SetMetadata` + `Reflector` 行为做一个准确的实现。让我重新设计: - -NestJS 中,**同一个 key** 多次 `SetMetadata` 时,后面的会覆盖前面的(因为 `Reflect.defineMetadata` 是覆盖语义)。但 NestJS 提供了 `Reflector.getAllAndMerge` 来解决这个问题。 - -对于我们的 AND/OR 语义,设计决策: -- 单个 `@RequirePermission('a', 'b')`:OR 关系(满足 a 或 b 即可通过) -- 需要 AND 的场景极少,可通过组合使用:在 handler 中手动取 metadata 后自己判断 - -其实设计文档中说: -- `@RequirePermission('student:create')` - 需要此权限 -- `@RequirePermission('bill:export-excel', 'bill:export-pdf')` - 满足任一即可(OR) -- `@RequirePermission('bill:view')` + `@RequirePermission('bill:delete')` - 两个都需满足(AND) - -但设计文档也指出:装饰器多次 SetMetadata 时 NestJS 自动合并为数组,`Reflector.get('permissions')` 返回 `[['bill:view'], ['bill:delete']]`。 - -这个描述不完全准确——实际上是 `reflector.getAllAndMerge` 能实现这个效果。让我按照设计文档的思路实现: - -使用 `Reflector.getAllAndMerge`: -- 单个 `@RequirePermission('a', 'b')`:返回 `['a', 'b']` -- 多次 `@RequirePermission('a') @RequirePermission('b')`:返回 `['a', 'b']`(合并为一维数组) - -等等,`getAllAndMerge` 会把所有结果 flatten 成一个数组。那如何区分 AND/OR? - -实际上重新读设计文档的话,设计文档说: -- "Reflector.get('permissions') 返回 [['bill:view'], ['bill:delete']]" —— 这表明设计文档期望 `getAllAndOverride` 返回二维数组。 - -实际上 NestJS 的 `SetMetadata` 连续调用确实会覆盖。但如果我们用 `@RequirePermission` 在 handler 上调用一次,在 class 上调用一次,`getAllAndOverride` 会取 handler 的值(override class)。 - -让我采用实际可行的方案:使用 `reflector.get` 加手动合并。对于多次 `@RequirePermission`,NestJS 的行为是每个装饰器独立调用 `SetMetadata`,最终存储在 `Reflect` 上的是 `[['a'], ['b']]`(数组的数组)... 实际上 `SetMetadata` 不支持 append。 - -让我采用最简单可行的方案:用 `reflector.getAllAndMerge`,它会把所有层级和所有调用的结果展平合并。对于单次调用 `@RequirePermission('a', 'b')`,返回 `['a', 'b']`(OR 语义)。对于不支持的 AND 场景,暂用 OR 替代,后续需要时再扩展。 - -实际代码实现如下。现在继续写 plan。 - -- [x] **Step 3 (修正版): 创建 PermissionGuard** - -```typescript -// backend/src/auth/guards/permission.guard.ts -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; -import { PERMISSION_KEY } from '../decorators/permission.decorator'; - -@Injectable() -export class PermissionGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - // 1. @Public() 豁免 - const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (isPublic) return true; - - // 2. 获取所需权限(getAllAndMerge 合并 handler+class 层的所有 metadata) - const requiredPermissions = this.reflector.getAllAndMerge( - PERMISSION_KEY, - [context.getHandler(), context.getClass()], - ); - // 无装饰器 = 默认拒绝 - if (!requiredPermissions || requiredPermissions.length === 0) return false; - - // 3. 从 JWT payload 获取用户权限 - const request = context.switchToHttp().getRequest(); - const user = request.user; - if (!user || !user.permissions || !Array.isArray(user.permissions)) return false; - - // 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过 - return requiredPermissions.some(p => user.permissions.includes(p)); - } -} -``` - -- [x] **Step 4: 编译验证** - -```bash -cd backend && npx tsc --noEmit -``` - -预期:无类型错误。 - -- [x] **Step 5: Commit** - -```bash -git add backend/src/auth/decorators/ backend/src/auth/guards/permission.guard.ts -git commit -m "feat(rbac): add PermissionGuard, @Public and @RequirePermission decorators" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 4: 修改 Auth 模块(JWT 变更 + 与 RbacModule 集成) - -**Files:** -- Modify: `backend/src/auth/auth.module.ts` -- Modify: `backend/src/auth/auth.service.ts` -- Modify: `backend/src/auth/auth.controller.ts` -- Modify: `backend/src/auth/strategies/jwt.strategy.ts` - -**Interfaces:** -- Consumes: `RbacService.getUserPermissions(userId)` (from Task 2) -- Produces: login 返回 `{ access_token, user: { id, username, name, roles, permissions } }`, JWT payload 包含 `{ sub, username, permissions }` - -- [x] **Step 1: 修改 AuthModule 导入 RbacModule** - -```typescript -// backend/src/auth/auth.module.ts -import { Module, forwardRef } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { User } from '../entities/user.entity'; -import { AuthService } from './auth.service'; -import { AuthController } from './auth.controller'; -import { JwtStrategy } from './strategies/jwt.strategy'; -import { RbacModule } from '../rbac/rbac.module'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([User]), - PassportModule, - JwtModule.registerAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), - signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') }, - }), - }), - forwardRef(() => RbacModule), - ], - controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [AuthService], -}) -export class AuthModule {} -``` - -注意:移除 `OnModuleInit` 实现,`initAdmin` 已迁移至 `RbacModule`。 - -- [x] **Step 2: 修改 AuthService.login() 集成权限查询** - -在 `backend/src/auth/auth.service.ts` 中: - -```typescript -import { Injectable, UnauthorizedException, forwardRef, Inject } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { JwtService } from '@nestjs/jwt'; -import * as bcrypt from 'bcryptjs'; -import { User } from '../entities/user.entity'; -import { LoginDto } from './dto/auth.dto'; -import { RbacService } from '../rbac/rbac.service'; - -const loginAttempts = new Map(); -const MAX_ATTEMPTS = 5; -const LOCK_MINUTES = 15; - -@Injectable() -export class AuthService { - constructor( - @InjectRepository(User) private userRepo: Repository, - private jwtService: JwtService, - @Inject(forwardRef(() => RbacService)) private rbacService: RbacService, - ) {} -``` - -然后修改 `login` 方法的 payload 部分: - -```typescript - async login(dto: LoginDto, ip?: string) { - const attemptKey = `${ip || 'unknown'}:${dto.username}`; - const attempt = loginAttempts.get(attemptKey); - - if (attempt?.lockedUntil && attempt.lockedUntil > new Date()) { - const remaining = Math.ceil((attempt.lockedUntil.getTime() - Date.now()) / 60000); - throw new UnauthorizedException(`账号已被临时锁定,请 ${remaining} 分钟后重试`); - } - - const user = await this.userRepo.findOne({ - where: { username: dto.username }, - relations: ['roles'], - }); - if (!user) { - this.recordFailedAttempt(attemptKey); - throw new UnauthorizedException('用户名或密码错误'); - } - if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员'); - const valid = await bcrypt.compare(dto.password, user.passwordHash); - if (!valid) { - this.recordFailedAttempt(attemptKey); - const att = loginAttempts.get(attemptKey); - const remaining = MAX_ATTEMPTS - (att?.count || 0); - if (remaining > 0) { - throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`); - } - throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`); - } - - loginAttempts.delete(attemptKey); - - user.lastLoginAt = new Date(); - await this.userRepo.save(user); - - // 获取用户权限 - const permissions = await this.rbacService.getUserPermissions(user.id); - const payload = { sub: user.id, username: user.username, permissions }; - - // 获取角色名称列表 - const roleNames = user.roles ? user.roles.filter(r => r.status === 1).map(r => r.name) : []; - - return { - access_token: this.jwtService.sign(payload), - user: { - id: user.id, - username: user.username, - name: user.name, - roles: roleNames, - permissions, - }, - }; - } -``` - -删除以下方法: -- `register()` — 迁移至 RbacService -- `findAllUsers()` — 迁移至 RbacService -- `updateUser()` — 迁移至 RbacService -- `resetPassword()` — 迁移至 RbacService -- `removeUser()` — 迁移至 RbacService -- `initAdmin()` — 迁移至 RbacService.seedData() - -保留 `validateUser()`(供 JwtStrategy 使用)和 `recordFailedAttempt()`(私有)。 - -- [x] **Step 3: 修改 AuthController** - -在 `backend/src/auth/auth.controller.ts` 中: - -```typescript -import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common'; -import { AuthService } from './auth.service'; -import { LoginDto } from './dto/auth.dto'; -import { JwtAuthGuard } from './guards/jwt-auth.guard'; -import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; -import { Throttle, UseGuards } from '@nestjs/throttler'; -import { Public } from './decorators/public.decorator'; - -@Controller('auth') -export class AuthController { - constructor(private authService: AuthService, private logService: OperationLogsService) {} - - @Public() - @Post('login') - @Throttle({ default: { ttl: 60000, limit: 5 } }) - async login(@Body() dto: LoginDto, @Req() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - try { - const result = await this.authService.login(dto, ipAddress); - await this.logService.log({ - userId: result.user.id, username: result.user.username, - module: '认证', action: '登录成功', - ipAddress, userAgent, status: 'success', - }); - return result; - } catch (e: any) { - await this.logService.log({ - username: dto.username, - module: '认证', action: '登录失败', - detail: e.message || '密码错误', - ipAddress, userAgent, status: 'fail', - }); - throw e; - } - } - - @UseGuards(JwtAuthGuard) - @Get('profile') - getProfile(@Request() req: any) { - return req.user; - } -} -``` - -**删除**: -- `POST /auth/register` → 迁移至 `/rbac/users POST` -- `GET /auth/users` → 迁移至 `/rbac/users GET` -- `PUT /auth/users/:id` → 迁移至 `/rbac/users/:id PUT` -- `PUT /auth/users/:id/password` → 迁移至 `/rbac/users/:id/password PUT` -- `DELETE /auth/users/:id` → 迁移至 `/rbac/users/:id DELETE` - -- [x] **Step 4: 修改 JwtStrategy** - -```typescript -// backend/src/auth/strategies/jwt.strategy.ts -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - ignoreExpiration: false, - secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), - }); - } - - async validate(payload: any) { - return { - id: payload.sub, - username: payload.username, - permissions: payload.permissions || [], - }; - } -} -``` - -- [x] **Step 5: 编译验证** - -```bash -cd backend && npx tsc --noEmit -``` - -预期:无类型错误。如有循环依赖错误,确认 `forwardRef` 已正确配置。 - -- [x] **Step 6: Commit** - -```bash -git add backend/src/auth/ -git commit -m "feat(rbac): integrate RbacService into AuthService login, update JWT payload" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 5: RbacController(角色 CRUD + 权限树 + 用户管理) - -**Files:** -- Create: `backend/src/rbac/rbac.controller.ts` -- Create: `backend/src/rbac/dto/rbac.dto.ts` - -**Interfaces:** -- Consumes: `RbacService` (Task 2), `JwtAuthGuard` (existing), `@RequirePermission` (Task 3) -- Produces: REST API endpoints for roles, permissions, and user management - -- [x] **Step 1: 创建 DTO** - -```typescript -// backend/src/rbac/dto/rbac.dto.ts -import { IsString, MinLength, IsOptional, IsArray, IsBoolean } from 'class-validator'; - -export class CreateRoleDto { - @IsString() - name: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - permissionIds?: number[]; -} - -export class UpdateRoleDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - permissionIds?: number[]; -} - -export class CreateUserDto { - @IsString() - username: string; - - @IsString() - @MinLength(4) - password: string; - - @IsString() - name: string; - - @IsOptional() - @IsArray() - roleIds?: number[]; -} - -export class UpdateUserDto { - @IsOptional() - @IsString() - username?: string; - - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsBoolean() - isActive?: boolean; - - @IsOptional() - @IsArray() - roleIds?: number[]; -} - -export class ResetPasswordDto { - @IsString() - @MinLength(4) - password: string; -} -``` - -- [x] **Step 2: 创建 RbacController** - -```typescript -// backend/src/rbac/rbac.controller.ts -import { - Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request, BadRequestException, -} from '@nestjs/common'; -import { RbacService } from './rbac.service'; -import { CreateRoleDto, UpdateRoleDto, CreateUserDto, UpdateUserDto, ResetPasswordDto } from './dto/rbac.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { RequirePermission } from '../auth/decorators/permission.decorator'; -import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; - -@UseGuards(JwtAuthGuard) -@Controller('rbac') -export class RbacController { - constructor( - private rbacService: RbacService, - private logService: OperationLogsService, - ) {} - - // ==================== 角色管理 ==================== - - @Get('roles') - @RequirePermission('role:view') - findAllRoles() { - return this.rbacService.findAllRoles(); - } - - @Get('roles/:id') - @RequirePermission('role:view') - findRoleById(@Param('id') id: string) { - return this.rbacService.findRoleById(+id); - } - - @Post('roles') - @RequirePermission('role:create') - async createRole(@Body() dto: CreateRoleDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.rbacService.createRole(dto); - await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`, ipAddress, userAgent }); - return result; - } - - @Put('roles/:id') - @RequirePermission('role:edit') - async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - try { - const result = await this.rbacService.updateRole(+id, dto); - await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto), ipAddress, userAgent }); - return result; - } catch (e: any) { - throw new BadRequestException(e.message); - } - } - - @Delete('roles/:id') - @RequirePermission('role:delete') - async deleteRole(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - try { - const result = await this.rbacService.deleteRole(+id); - await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '删除角色', targetId: +id, targetType: 'role', ipAddress, userAgent }); - return result; - } catch (e: any) { - throw new BadRequestException(e.message); - } - } - - // ==================== 权限管理 ==================== - - @Get('permissions') - @RequirePermission('role:view') - findAllPermissions() { - return this.rbacService.findAllPermissions(); - } - - @Get('permissions/tree') - @RequirePermission('role:view') - getPermissionTree() { - return this.rbacService.getPermissionTree(); - } - - // ==================== 用户管理 ==================== - - @Get('users') - @RequirePermission('user:view') - getUsers() { - return this.rbacService.findAllUsers(); - } - - @Post('users') - @RequirePermission('user:create') - async createUser(@Body() dto: CreateUserDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - try { - const result = await this.rbacService.createUser(dto); - await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`, ipAddress, userAgent }); - return result; - } catch (e: any) { - throw new BadRequestException(e.message); - } - } - - @Put('users/:id') - @RequirePermission('user:edit') - async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - try { - const result = await this.rbacService.updateUser(+id, dto); - await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto), ipAddress, userAgent }); - return result; - } catch (e: any) { - throw new BadRequestException(e.message); - } - } - - @Put('users/:id/password') - @RequirePermission('user:reset-password') - async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - try { - const result = await this.rbacService.resetPassword(+id, dto.password); - await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '重置密码', targetId: +id, targetType: 'user', ipAddress, userAgent }); - return result; - } catch (e: any) { - throw new BadRequestException(e.message); - } - } - - @Delete('users/:id') - @RequirePermission('user:delete') - async deleteUser(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - try { - const result = await this.rbacService.deleteUser(+id); - await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '删除账号', targetId: +id, targetType: 'user', ipAddress, userAgent }); - return result; - } catch (e: any) { - throw new BadRequestException(e.message); - } - } -} -``` - -- [x] **Step 3: 在 RbacService 中补充用户管理方法** - -在 `backend/src/rbac/rbac.service.ts` 中追加: - -```typescript - // ---- 用户管理 ---- - - async findAllUsers() { - const users = await this.userRepo.find({ - relations: ['roles'], - order: { createdAt: 'DESC' }, - }); - return users.map(u => ({ - id: u.id, - username: u.username, - name: u.name, - isActive: u.isActive, - lastLoginAt: u.lastLoginAt, - createdAt: u.createdAt, - updatedAt: u.updatedAt, - roles: u.roles?.map(r => ({ id: r.id, name: r.name })) || [], - })); - } - - async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) { - const exists = await this.userRepo.findOne({ where: { username: dto.username } }); - if (exists) throw new Error('用户名已存在'); - const bcrypt = require('bcryptjs'); - const hash = await bcrypt.hash(dto.password, 10); - const user = this.userRepo.create({ username: dto.username, passwordHash: hash, name: dto.name }); - if (dto.roleIds && dto.roleIds.length > 0) { - user.roles = await this.roleRepo.findByIds(dto.roleIds); - } - await this.userRepo.save(user); - return { message: '用户创建成功' }; - } - - async updateUser(id: number, dto: { username?: string; name?: string; isActive?: boolean; roleIds?: number[] }) { - const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] }); - if (!user) throw new Error('用户不存在'); - if (dto.username !== undefined && dto.username !== user.username) { - const exists = await this.userRepo.findOne({ where: { username: dto.username } }); - if (exists) throw new Error('用户名已存在'); - user.username = dto.username; - } - if (dto.name !== undefined) user.name = dto.name; - if (dto.isActive !== undefined) user.isActive = dto.isActive; - if (dto.roleIds !== undefined) { - user.roles = dto.roleIds.length > 0 - ? await this.roleRepo.findByIds(dto.roleIds) - : []; - } - await this.userRepo.save(user); - return { message: '更新成功' }; - } - - async resetPassword(id: number, newPassword: string) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - const bcrypt = require('bcryptjs'); - user.passwordHash = await bcrypt.hash(newPassword, 10); - await this.userRepo.save(user); - return { message: '密码已重置' }; - } - - async deleteUser(id: number) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - if (user.username === 'admin') throw new Error('不能删除默认管理员'); - await this.userRepo.remove(user); - return { message: '用户已删除' }; - } -``` - -- [x] **Step 4: 编译验证** - -```bash -cd backend && npx tsc --noEmit -``` - -预期:无类型错误。 - -- [x] **Step 5: Commit** - -```bash -git add backend/src/rbac/ -git commit -m "feat(rbac): add RbacController with role CRUD, permissions tree, and user management endpoints" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 6: AppModule 注册 RbacModule 和 PermissionGuard - -**Files:** -- Modify: `backend/src/app.module.ts` - -**Interfaces:** -- Produces: PermissionGuard 作为 APP_GUARD 全局生效;RbacModule 初始化种子数据 - -- [x] **Step 1: 修改 AppModule** - -在 `backend/src/app.module.ts` 中: - -1. 导入 `PermissionGuard` 和 `RbacModule` -2. entities 列表增加 `Permission`、`Role` -3. imports 增加 `RbacModule` -4. providers 增加 `{ provide: APP_GUARD, useClass: PermissionGuard }` - -```typescript -import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import { - Student, Room, Occupancy, RoomExpense, PersonalExpense, - Bill, BillItem, User, OperationLog, Deposit, Classroom, - Tenant, ClassroomRental, Permission, Role, -} from './entities'; -import { AuthModule } from './auth/auth.module'; -import { RbacModule } from './rbac/rbac.module'; -import { StudentsModule } from './students/students.module'; -import { RoomsModule } from './rooms/rooms.module'; -import { OccupanciesModule } from './occupancies/occupancies.module'; -import { ExpensesModule } from './expenses/expenses.module'; -import { BillsModule } from './bills/bills.module'; -import { DashboardModule } from './dashboard/dashboard.module'; -import { OperationLogsModule } from './operation-logs/operation-logs.module'; -import { DepositsModule } from './deposits/deposits.module'; -import { ClassroomsModule } from './classrooms/classrooms.module'; -import { TenantsModule } from './tenants/tenants.module'; -import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module'; -import { PermissionGuard } from './auth/guards/permission.guard'; - -@Module({ - imports: [ - ConfigModule.forRoot({ isGlobal: true }), - ThrottlerModule.forRoot([{ - ttl: 60000, - limit: 100, - }]), - TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService): any => { - const dbType = config.get('DB_TYPE', 'sqlite'); - const allEntities = [ - Student, Room, Occupancy, RoomExpense, PersonalExpense, - Bill, BillItem, User, OperationLog, Deposit, Classroom, - Tenant, ClassroomRental, Permission, Role, - ]; - if (dbType === 'mysql') { - return { - type: 'mysql' as const, - host: config.get('DB_HOST', 'localhost'), - port: config.get('DB_PORT', 3306), - username: config.get('DB_USERNAME', 'root'), - password: config.get('DB_PASSWORD', ''), - database: config.get('DB_DATABASE', 'dorm_billing'), - entities: allEntities, - synchronize: true, - charset: 'utf8mb4', - }; - } - return { - type: 'better-sqlite3' as const, - database: config.get('DB_DATABASE', 'dorm_billing.db'), - entities: allEntities, - synchronize: true, - }; - }, - }), - AuthModule, - RbacModule, - StudentsModule, - RoomsModule, - OccupanciesModule, - ExpensesModule, - BillsModule, - DashboardModule, - OperationLogsModule, - DepositsModule, - ClassroomsModule, - TenantsModule, - ClassroomRentalsModule, - ], - providers: [ - { provide: APP_GUARD, useClass: ThrottlerGuard }, - { provide: APP_GUARD, useClass: PermissionGuard }, - ], -}) -export class AppModule {} -``` - -- [x] **Step 2: 启动验证** - -```bash -cd backend && npm run start:dev -``` - -预期: -- 种子数据初始化日志输出(42 个权限点、4 个角色) -- 无崩溃无错误 -- 尝试不带 token 访问任意接口 → 返回 403 - -- [x] **Step 3: Commit** - -```bash -git add backend/src/app.module.ts -git commit -m "feat(rbac): register RbacModule and PermissionGuard in AppModule" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 7: 现有接口批量加 @RequirePermission - -**Files:** -- Modify: `backend/src/students/students.controller.ts` -- Modify: `backend/src/rooms/rooms.controller.ts` -- Modify: `backend/src/occupancies/occupancies.controller.ts` -- Modify: `backend/src/expenses/expenses.controller.ts` -- Modify: `backend/src/bills/bills.controller.ts` -- Modify: `backend/src/deposits/deposits.controller.ts` -- Modify: `backend/src/classrooms/classrooms.controller.ts` -- Modify: `backend/src/tenants/tenants.controller.ts` -- Modify: `backend/src/classroom-rentals/classroom-rentals.controller.ts` -- Modify: `backend/src/dashboard/dashboard.controller.ts` -- Modify: `backend/src/operation-logs/operation-logs.controller.ts` - -**Interfaces:** -- Consumes: `@RequirePermission` (Task 3) -- Produces: 所有业务接口受权限守卫保护 - -- [x] **Step 1: 为各 Controller 添加 @RequirePermission** - -对每个 Controller,在已有的 `@UseGuards(JwtAuthGuard)` 基础上,为每个路由方法添加 `@RequirePermission` 装饰器。权限码映射如下: - -| Controller | 方法 | 权限码 | -|-----------|------|--------| -| StudentsController | findAll | `student:view` | -| StudentsController | findOne | `student:view` | -| StudentsController | create | `student:create` | -| StudentsController | update | `student:edit` | -| StudentsController | remove | `student:delete` | -| StudentsController | importExcel | `student:import` | -| StudentsController | exportExcel | `student:export` | -| RoomsController | findAll | `room:view` | -| RoomsController | findOne | `room:view` | -| RoomsController | create | `room:create` | -| RoomsController | update | `room:edit` | -| RoomsController | remove | `room:delete` | -| RoomsController | exportExcel | `room:view` | -| RoomsController | importExcel | `room:create` | -| OccupanciesController | findAll | `occupancy:view` | -| OccupanciesController | checkin | `occupancy:checkin` | -| OccupanciesController | checkout | `occupancy:checkout` | -| OccupanciesController | transfer | `occupancy:transfer` | -| ExpensesController | findAll | `expense:view` | -| ExpensesController | create | `expense:create` | -| ExpensesController | update | `expense:edit` | -| ExpensesController | remove | `expense:delete` | -| BillsController | findAll | `bill:view` | -| BillsController | generate | `bill:generate` | -| BillsController | confirm | `bill:confirm` | -| BillsController | remove | `bill:delete` | -| BillsController | exportExcel | `bill:export-excel` | -| BillsController | exportPdf | `bill:export-pdf` | -| DepositsController | findAll | `deposit:view` | -| DepositsController | create | `deposit:create` | -| DepositsController | update | `deposit:edit` | -| DepositsController | remove | `deposit:delete` | -| ClassroomsController | findAll | `classroom:view` | -| ClassroomsController | create | `classroom:create` | -| ClassroomsController | update | `classroom:edit` | -| ClassroomsController | remove | `classroom:delete` | -| TenantsController | findAll | `tenant:view` | -| TenantsController | create | `tenant:create` | -| TenantsController | update | `tenant:edit` | -| TenantsController | remove | `tenant:delete` | -| ClassroomRentalsController | findAll | `rental:view` | -| ClassroomRentalsController | create | `rental:create` | -| ClassroomRentalsController | update | `rental:edit` | -| ClassroomRentalsController | remove | `rental:delete` | -| DashboardController | 全部 | `dashboard:view` | -| OperationLogsController | 全部 | `log:view` | - -每个 Controller 的修改模式为:在文件头部 import `RequirePermission`,然后在每个方法上添加对应装饰器。例如 StudentsController: - -```typescript -// 在 imports 中添加 -import { RequirePermission } from '../auth/decorators/permission.decorator'; - -// 每个方法前添加 -@Get() -@RequirePermission('student:view') -findAll(...) { ... } - -@Get(':id') -@RequirePermission('student:view') -findOne(...) { ... } - -@Post() -@RequirePermission('student:create') -async create(...) { ... } - -@Put(':id') -@RequirePermission('student:edit') -async update(...) { ... } - -@Delete(':id') -@RequirePermission('student:delete') -async remove(...) { ... } -``` - -**注意**: -- Controller 上已有 `@UseGuards(JwtAuthGuard)` 不做修改 -- `rooms.controller.ts` 的 `getOverview`、`getVisual`、`downloadTemplate` 等方法同样需要 `room:view` 权限 -- `bills.controller.ts` 中 `exportExcel` 需要 `bill:export-excel`,`exportPdf` 需要 `bill:export-pdf` -- `dashboard.controller.ts` 和 `operation-logs.controller.ts` 的类级别加 `@RequirePermission('dashboard:view')` 和 `@RequirePermission('log:view')` 可以减少重复 - -- [x] **Step 2: 编译验证** - -```bash -cd backend && npx tsc --noEmit -``` - -预期:无类型错误。所有 Controller 的装饰器正确引用。 - -- [x] **Step 3: Commit** - -```bash -git add backend/src/students/ backend/src/rooms/ backend/src/occupancies/ backend/src/expenses/ backend/src/bills/ backend/src/deposits/ backend/src/classrooms/ backend/src/tenants/ backend/src/classroom-rentals/ backend/src/dashboard/ backend/src/operation-logs/ -git commit -m "feat(rbac): add @RequirePermission decorators to all business controllers" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 8: 前端权限基础设施 - -**Files:** -- Create: `frontend/src/hooks/usePermission.ts` -- Create: `frontend/src/components/PermissionButton.tsx` -- Create: `frontend/src/components/PermissionRoute.tsx` -- Modify: `frontend/src/pages/Login/index.tsx` -- Modify: `frontend/src/api/index.ts` - -**Interfaces:** -- Produces: `usePermission()` hook, `PermissionButton` 组件, `PermissionRoute` 组件 - -- [x] **Step 1: 创建 usePermission hook** - -```typescript -// frontend/src/hooks/usePermission.ts -import { useMemo } from 'react'; - -export function usePermission() { - const permissions: string[] = useMemo(() => { - try { - return JSON.parse(localStorage.getItem('permissions') || '[]'); - } catch { - return []; - } - }, []); - - const hasPermission = (code: string): boolean => permissions.includes(code); - - const hasAnyPermission = (...codes: string[]): boolean => - codes.some(c => permissions.includes(c)); - - const hasAllPermissions = (...codes: string[]): boolean => - codes.every(c => permissions.includes(c)); - - return { permissions, hasPermission, hasAnyPermission, hasAllPermissions }; -} -``` - -- [x] **Step 2: 创建 PermissionButton 组件** - -```typescript -// frontend/src/components/PermissionButton.tsx -import React from 'react'; -import { Button, ButtonProps } from 'antd'; -import { usePermission } from '../hooks/usePermission'; - -interface PermissionButtonProps extends ButtonProps { - permission: string; - children: React.ReactNode; -} - -const PermissionButton: React.FC = ({ permission, children, ...btnProps }) => { - const { hasPermission } = usePermission(); - if (!hasPermission(permission)) return null; - return ; -}; - -export default PermissionButton; -``` - -- [x] **Step 3: 创建 PermissionRoute 组件** - -```typescript -// frontend/src/components/PermissionRoute.tsx -import React from 'react'; -import { Result } from 'antd'; -import { usePermission } from '../hooks/usePermission'; - -interface PermissionRouteProps { - permission: string; - children: React.ReactNode; -} - -const PermissionRoute: React.FC = ({ permission, children }) => { - const { hasPermission } = usePermission(); - if (!hasPermission(permission)) { - return ( - - ); - } - return <>{children}; -}; - -export default PermissionRoute; -``` - -- [x] **Step 4: 修改 Login 页面存储 permissions** - -在 `frontend/src/pages/Login/index.tsx` 的 `onFinish` 方法中,登录成功后存储 permissions: - -```typescript -// 在 try 块中,现有代码之后添加: -localStorage.setItem('permissions', JSON.stringify(res.user.permissions || [])); -``` - -完整变更: - -```typescript -const onFinish = async (values: any) => { - setLoading(true); - try { - const res: any = await api.post('/auth/login', values); - localStorage.setItem('token', res.access_token); - localStorage.setItem('user', JSON.stringify(res.user)); - localStorage.setItem('permissions', JSON.stringify(res.user.permissions || [])); - message.success('登录成功'); - navigate('/dashboard'); - } catch (err: any) { - message.error(err?.message || '登录失败'); - } finally { - setLoading(false); - } -}; -``` - -同时修改退出登录时的清理: - -```typescript -// 在 MainLayout.tsx 的 handleLogout 中(Task 10 会处理), -// 但此处先在 Login 页确保权限被正确存储 -``` - -- [x] **Step 5: 修改 axios 拦截器处理 403** - -在 `frontend/src/api/index.ts` 中,在 401 处理逻辑之后增加 403 处理: - -```typescript -api.interceptors.response.use( - (res) => res.data, - (err) => { - if (err.response?.status === 401) { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - localStorage.removeItem('permissions'); - window.location.href = '/login'; - } - if (err.response?.status === 403) { - // 403 不跳转登录,仅提示权限不足 - // 使用动态 import 避免循环依赖,或者简单 console 处理 - // 由于 antd message 在此处无法直接使用,延迟处理: - const msg = err.response?.data?.message || '权限不足'; - console.warn('[403]', msg); - } - return Promise.reject(err.response?.data || err); - }, -); -``` - -**注意**:此处 403 提示需在各页面调用 api 时由 catch 块处理显示 message。具体在 Task 10 中各页面的 api 调用 catch 块中增加 403 判断。 - -- [x] **Step 6: 编译验证** - -```bash -cd frontend && npx tsc -b --noEmit -``` - -预期:无类型错误(可能需要处理 React 19 + Ant Design 6 的类型兼容问题,如有则忽略第三方类型错误)。 - -- [x] **Step 7: Commit** - -```bash -git add frontend/src/hooks/ frontend/src/components/PermissionButton.tsx frontend/src/components/PermissionRoute.tsx frontend/src/pages/Login/ frontend/src/api/ -git commit -m "feat(frontend): add usePermission hook, PermissionButton, PermissionRoute, and 403 handling" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 9: 前端角色管理和权限一览页面 - -**Files:** -- Create: `frontend/src/pages/Roles/index.tsx` -- Create: `frontend/src/pages/Permissions/index.tsx` - -**Interfaces:** -- Consumes: `usePermission` hook (Task 8), `/rbac/roles` API, `/rbac/permissions` API (Task 5) -- Produces: 角色管理页面(CRUD + 权限勾选),权限一览页面(只读分组展示) - -- [x] **Step 1: 创建角色管理页面** - -```tsx -// frontend/src/pages/Roles/index.tsx -import React, { useEffect, useState } from 'react'; -import { Table, Button, Modal, Form, Input, Space, Tag, Popconfirm, message, Card, Checkbox } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; -import api from '../../api'; -import { usePermission } from '../../hooks/usePermission'; -import PermissionButton from '../../components/PermissionButton'; - -interface PermissionItem { - id: number; - code: string; - name: string; - group: string; -} - -interface RoleItem { - id: number; - name: string; - description: string; - isSystem: boolean; - status: number; - permissions: PermissionItem[]; -} - -const RolesPage: React.FC = () => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); - const [form] = Form.useForm(); - const [selectedPermIds, setSelectedPermIds] = useState([]); - - const fetchData = async () => { - setLoading(true); - try { - const [roles, permTree] = await Promise.all([ - api.get('/rbac/roles') as Promise, - api.get('/rbac/permissions/tree') as Promise<{ group: string; permissions: PermissionItem[] }[]>, - ]); - setData(roles); - setAllPerms(permTree); - } catch (e) { console.error(e); } - setLoading(false); - }; - - useEffect(() => { fetchData(); }, []); - - const handleAdd = () => { - setEditing(null); - form.resetFields(); - setSelectedPermIds([]); - setModalOpen(true); - }; - - const handleEdit = (record: RoleItem) => { - setEditing(record); - form.setFieldsValue({ name: record.name, description: record.description }); - setSelectedPermIds(record.permissions.map(p => p.id)); - setModalOpen(true); - }; - - const handleSubmit = async () => { - const values = await form.validateFields(); - try { - if (editing) { - await api.put(`/rbac/roles/${editing.id}`, { name: values.name, description: values.description, permissionIds: selectedPermIds }); - message.success('角色更新成功'); - } else { - await api.post('/rbac/roles', { name: values.name, description: values.description, permissionIds: selectedPermIds }); - message.success('角色创建成功'); - } - setModalOpen(false); - fetchData(); - } catch (e: any) { message.error(e.message || '操作失败'); } - }; - - const handleDelete = async (id: number) => { - try { - await api.delete(`/rbac/roles/${id}`); - message.success('角色已删除'); - fetchData(); - } catch (e: any) { message.error(e.message || '删除失败'); } - }; - - const groupNames: Record = { - dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理', - expense: '费用管理', bill: '账单管理', deposit: '押金管理', - classroom: '教室管理', tenant: '租赁方', rental: '租赁订单', - log: '操作日志', user: '用户管理', role: '角色管理', - }; - - const columns = [ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '名称', dataIndex: 'name', width: 120 }, - { title: '描述', dataIndex: 'description', width: 200, ellipsis: true }, - { - title: '权限标签', dataIndex: 'permissions', width: 150, ellipsis: true, - render: (perms: PermissionItem[]) => perms?.length > 0 - ? {perms.length} 个权限 - : 无权限, - }, - { - title: '系统', dataIndex: 'isSystem', width: 70, - render: (v: boolean) => v ? 系统 : null, - }, - { - title: '操作', width: 160, fixed: 'right' as const, - render: (_: any, record: RoleItem) => ( - - } onClick={() => handleEdit(record)}> - 编辑 - - {!record.isSystem && ( - - handleDelete(record.id)}> - - - - )} - - ), - }, - ]; - - const handleGroupCheckAll = (group: string, checked: boolean) => { - const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || []; - if (checked) { - setSelectedPermIds(prev => [...new Set([...prev, ...groupPermIds])]); - } else { - setSelectedPermIds(prev => prev.filter(id => !groupPermIds.includes(id))); - } - }; - - const isGroupAllChecked = (group: string) => { - const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || []; - return groupPermIds.length > 0 && groupPermIds.every(id => selectedPermIds.includes(id)); - }; - - const isGroupIndeterminate = (group: string) => { - const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || []; - const checkedCount = groupPermIds.filter(id => selectedPermIds.includes(id)).length; - return checkedCount > 0 && checkedCount < groupPermIds.length; - }; - - return ( -
-
-

角色管理

- } onClick={handleAdd}> - 新增角色 - -
- - - setModalOpen(false)} - width={700} - destroyOnClose - > -
- - - - - - - -
- {allPerms.map(group => ( - handleGroupCheckAll(group.group, e.target.checked)} - > - {groupNames[group.group] || group.group} - - } - style={{ marginBottom: 8 }} - > - setSelectedPermIds(vals as number[])} - > - - {group.permissions.map(p => ( - {p.name} - ))} - - - - ))} -
-
- -
- - ); -}; - -export default RolesPage; -``` - -- [x] **Step 2: 创建权限一览页面** - -```tsx -// frontend/src/pages/Permissions/index.tsx -import React, { useEffect, useState } from 'react'; -import { Card, Tag, Input, Space, Spin } from 'antd'; -import api from '../../api'; - -interface PermissionItem { - id: number; - code: string; - name: string; - group: string; - description: string; -} - -const PermissionsPage: React.FC = () => { - const [permTree, setPermTree] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); - const [loading, setLoading] = useState(false); - const [search, setSearch] = useState(''); - - const groupNames: Record = { - dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理', - expense: '费用管理', bill: '账单管理', deposit: '押金管理', - classroom: '教室管理', tenant: '租赁方', rental: '租赁订单', - log: '操作日志', user: '用户管理', role: '角色管理', - }; - - useEffect(() => { - setLoading(true); - api.get('/rbac/permissions/tree') - .then((res: any) => setPermTree(res)) - .catch(console.error) - .finally(() => setLoading(false)); - }, []); - - const filteredTree = search - ? permTree.map(g => ({ - ...g, - permissions: g.permissions.filter(p => - p.name.includes(search) || p.code.includes(search) - ), - })).filter(g => g.permissions.length > 0) - : permTree; - - if (loading) return ; - - return ( -
-
-

权限一览

- !e.target.value && setSearch('')} - /> -
- - {filteredTree.map(group => ( - {groupNames[group.group] || group.group} ({group.permissions.length})} - size="small" - > - - {group.permissions.map(p => ( - - {p.name} {p.code} - - ))} - - - ))} - -
- ); -}; - -export default PermissionsPage; -``` - -- [x] **Step 3: 编译验证** - -```bash -cd frontend && npx tsc -b --noEmit -``` - -预期:无新增类型错误。 - -- [x] **Step 4: Commit** - -```bash -git add frontend/src/pages/Roles/ frontend/src/pages/Permissions/ -git commit -m "feat(frontend): add Roles management page and Permissions overview page" -``` - -archived-with: 2026-07-03-rbac-refactor ---- - -### Task 10: 前端路由、菜单和用户管理页面适配 - -**Files:** -- Modify: `frontend/src/App.tsx` -- Modify: `frontend/src/layouts/MainLayout.tsx` -- Modify: `frontend/src/pages/Users/index.tsx` -- Modify: 各业务页面中需要权限控制的按钮(约 11 个页面) - -**Interfaces:** -- Consumes: `usePermission`, `PermissionRoute`, `PermissionButton` (Task 8), `/rbac/users` API (Task 5) - -- [x] **Step 1: 修改 App.tsx 添加路由和权限包装** - -```tsx -// frontend/src/App.tsx -import React from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import { ConfigProvider, App as AntdApp } from 'antd'; -import zhCN from 'antd/es/locale/zh_CN'; -import MainLayout from './layouts/MainLayout'; -import LoginPage from './pages/Login'; -import DashboardPage from './pages/Dashboard'; -import StudentsPage from './pages/Students'; -import RoomsPage from './pages/Rooms'; -import OccupanciesPage from './pages/Occupancies'; -import ExpensesPage from './pages/Expenses'; -import BillsPage from './pages/Bills'; -import RoomVisualPage from './pages/RoomVisual'; -import OperationLogsPage from './pages/OperationLogs'; -import UsersPage from './pages/Users'; -import DepositsPage from './pages/Deposits'; -import ClassroomsPage from './pages/Classrooms'; -import TenantsPage from './pages/Tenants'; -import ClassroomRentalsPage from './pages/ClassroomRentals'; -import ClassroomSchedulePage from './pages/ClassroomSchedule'; -import RolesPage from './pages/Roles'; -import PermissionsPage from './pages/Permissions'; -import PermissionRoute from './components/PermissionRoute'; - -const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const token = localStorage.getItem('token'); - return token ? <>{children} : ; -}; - -const App: React.FC = () => { - return ( - - - - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - ); -}; - -export default App; -``` - -- [x] **Step 2: 修改 MainLayout 菜单过滤** - -```tsx -// frontend/src/layouts/MainLayout.tsx -// 核心变更: -// 1. 导入 usePermission hook -// 2. 为每个菜单项添加 permission 字段 -// 3. 使用 permissions 数组过滤(替代原来的 role === 'admin' 和 allowedMenus 逻辑) -// 4. 添加角色管理和权限一览菜单项 -// 5. 退出登录时清除 permissions - -import React, { useState, useEffect } from 'react'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Layout, Menu, Button, Avatar, Dropdown, Drawer } from 'antd'; -import { - DashboardOutlined, TeamOutlined, HomeOutlined, SwapOutlined, - DollarOutlined, FileTextOutlined, LogoutOutlined, UserOutlined, - MenuFoldOutlined, MenuUnfoldOutlined, AppstoreOutlined, - AuditOutlined, SettingOutlined, WalletOutlined, ReadOutlined, - TagsOutlined, FileProtectOutlined, CalendarOutlined, - SafetyOutlined, KeyOutlined, -} from '@ant-design/icons'; -import { usePermission } from '../hooks/usePermission'; - -const { Header, Sider, Content } = Layout; - -interface MenuItemType { - key: string; - icon: React.ReactNode; - label: string; - permission?: string; - children?: MenuItemType[]; -} - -const allMenuItems: MenuItemType[] = [ - { key: '/dashboard', icon: , label: '数据面板', permission: 'dashboard:view' }, - { key: '/room-visual', icon: , label: '宿舍总览', permission: 'room:view' }, - { key: '/students', icon: , label: '学生管理', permission: 'student:view' }, - { key: '/rooms', icon: , label: '宿舍管理', permission: 'room:view' }, - { key: '/occupancies', icon: , label: '入住管理', permission: 'occupancy:view' }, - { key: '/expenses', icon: , label: '费用录入', permission: 'expense:view' }, - { key: '/deposits', icon: , label: '押金管理', permission: 'deposit:view' }, - { key: '/bills', icon: , label: '账单管理', permission: 'bill:view' }, - { - key: 'classroom-group', - icon: , - label: '教室管理', - permission: 'classroom:view', - children: [ - { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'classroom:view' }, - { key: '/classrooms', icon: , label: '教室列表', permission: 'classroom:view' }, - { key: '/classroom-rentals', icon: , label: '租赁订单', permission: 'rental:view' }, - { key: '/tenants', icon: , label: '租赁方', permission: 'tenant:view' }, - ], - }, - { key: '/operation-logs', icon: , label: '操作日志', permission: 'log:view' }, - { key: '/roles', icon: , label: '角色管理', permission: 'role:view' }, - { key: '/permissions', icon: , label: '权限一览', permission: 'role:view' }, - { key: '/users', icon: , label: '账号管理', permission: 'user:view' }, -]; - -const MainLayout: React.FC = () => { - const [collapsed, setCollapsed] = useState(false); - const [isMobile, setIsMobile] = useState(window.innerWidth < 768); - const [drawerOpen, setDrawerOpen] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - const user = JSON.parse(localStorage.getItem('user') || '{}'); - const { hasPermission } = usePermission(); - - useEffect(() => { - const handleResize = () => setIsMobile(window.innerWidth < 768); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - // 按 permission 过滤菜单 - const filterByPermission = (items: MenuItemType[]): MenuItemType[] => { - return items - .map(item => { - if (item.children) { - const kids = filterByPermission(item.children); - if (kids.length === 0) return null; - return { ...item, children: kids }; - } - if (!item.permission) return item; - return hasPermission(item.permission) ? item : null; - }) - .filter(Boolean) as MenuItemType[]; - }; - - const menuItems = filterByPermission(allMenuItems); - - const handleLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - localStorage.removeItem('permissions'); - navigate('/login'); - }; - - const handleMenuClick = (key: string) => { - navigate(key); - if (isMobile) setDrawerOpen(false); - }; - - const transformToMenuItems = (items: MenuItemType[]): any[] => { - return items.map(item => ({ - key: item.key, - icon: item.icon, - label: item.label, - children: item.children ? transformToMenuItems(item.children) : undefined, - })); - }; - - const menuContent = ( - handleMenuClick(key)} - style={{ border: 'none' }} - /> - ); - - return ( - - {!isMobile && ( - -
- {collapsed ? '恭' : '恭学教育基地'} -
- {menuContent} -
- )} - {isMobile && ( - setDrawerOpen(false)} width={240} styles={{ body: { padding: 0 } }} title="恭学教育基地"> - {menuContent} - - )} - -
-
- - - -
-
- ); -}; - -export default MainLayout; -``` - -- [x] **Step 3: 重构用户管理页面** - -在 `frontend/src/pages/Users/index.tsx` 中: -1. API 端点从 `/auth/users` 改为 `/rbac/users`,创建用户从 `/auth/register` 改为 `/rbac/users POST` -2. 角色列从单一 Tag(admin/operator)改为多角色 Tag 列表 -3. 编辑弹窗角色从 Select 单选改为 Select mode="multiple" -4. 移除 `allowedMenus` 相关代码(MENU_OPTIONS 常量、Checkbox.Group) -5. 导入并加载可选角色列表(从 `/rbac/roles`) - -```tsx -// frontend/src/pages/Users/index.tsx -import React, { useEffect, useState } from 'react'; -import { Table, Button, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm, message } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; - -const UsersPage: React.FC = () => { - const [data, setData] = useState([]); - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [pwdModalOpen, setPwdModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [resetTarget, setResetTarget] = useState(null); - const [form] = Form.useForm(); - const [pwdForm] = Form.useForm(); - - const fetchData = async () => { - setLoading(true); - try { - const [users, rolesRes] = await Promise.all([ - api.get('/rbac/users') as Promise, - api.get('/rbac/roles') as Promise, - ]); - setData(users); - setRoles(rolesRes); - } catch (e) { console.error(e); } - setLoading(false); - }; - - useEffect(() => { fetchData(); }, []); - - const handleAdd = () => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }; - - const handleEdit = (record: any) => { - setEditing(record); - form.setFieldsValue({ - username: record.username, - name: record.name, - isActive: record.isActive, - roleIds: record.roles?.map((r: any) => r.id) || [], - }); - setModalOpen(true); - }; - - const handleSubmit = async () => { - const values = await form.validateFields(); - try { - if (editing) { - await api.put(`/rbac/users/${editing.id}`, { username: values.username, name: values.name, isActive: values.isActive, roleIds: values.roleIds || [] }); - message.success('更新成功'); - } else { - await api.post('/rbac/users', { username: values.username, password: values.password, name: values.name, roleIds: values.roleIds || [] }); - message.success('创建成功'); - } - setModalOpen(false); - fetchData(); - } catch (e: any) { message.error(e.message || '操作失败'); } - }; - - const handleDelete = async (id: number) => { - try { - await api.delete(`/rbac/users/${id}`); - message.success('已删除'); - fetchData(); - } catch (e: any) { message.error(e.message || '删除失败'); } - }; - - const handleResetPwd = (record: any) => { - setResetTarget(record); - pwdForm.resetFields(); - setPwdModalOpen(true); - }; - - const handlePwdSubmit = async () => { - const values = await pwdForm.validateFields(); - try { - await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password }); - message.success('密码已重置'); - setPwdModalOpen(false); - } catch (e: any) { message.error(e.message || '操作失败'); } - }; - - const columns = [ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '用户名', dataIndex: 'username', width: 120 }, - { title: '姓名', dataIndex: 'name', width: 120 }, - { - title: '角色', dataIndex: 'roles', width: 200, - render: (v: any[]) => v && v.length > 0 - ? v.map(r => {r.name}) - : 无角色, - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => {v ? '启用' : '禁用'}, - }, - { - title: '最后登录', dataIndex: 'lastLoginAt', width: 170, - render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-', - }, - { - title: '创建时间', dataIndex: 'createdAt', width: 170, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), - }, - { - title: '操作', width: 220, fixed: 'right' as const, - render: (_: any, record: any) => ( - - } onClick={() => handleEdit(record)}>编辑 - } onClick={() => handleResetPwd(record)}>重置密码 - {record.username !== 'admin' && ( - - handleDelete(record.id)}> - - - - )} - - ), - }, - ]; - - return ( -
-
-

账号管理

- } onClick={handleAdd}>新增账号 -
-
- - setModalOpen(false)} destroyOnClose> -
- - - - {!editing && ( - - - - )} - - - - {editing && ( - - - - )} - -
` 组件若无 `scroll={{ x }}` 则添加之,列少(<=6)用 `max-content`,列中(7-10)用 `800`,列多(>10)用 `1000` -- 弹窗宽度由全局 CSS `max-width: calc(100vw - 24px)` 覆盖,组件级不做重复处理 -- 编译必须通过:`npm run build` 无 TypeScript 错误 -- 命名约定:从 `expenseTypeMap` 等现有映射扩展,不新建无意义的变量名 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 1: 全局 CSS 三断点体系 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/index.css` - -**Interfaces:** -- Produces: 三个 `@media` 层级的 CSS 规则,供所有页面共用 -- 断点规范:手机 `(max-width: 575px)`、平板 `(min-width: 576px) and (max-width: 991px)` - -**Description:** 将当前仅有单断点(max-width: 767px)的 index.css 改为三断点体系,同时添加全局表格容器横向滚动和通用组件响应式样式。 - -- [x] **Step 1: 替换 index.css 中的 @media 规则** - -将当前文件内容替换为以下完整版本: - -```css -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -#root { - min-height: 100vh; -} - -/* === 通用:表格容器横向滚动 === */ -.ant-table-wrapper { - overflow-x: auto; -} - -/* === 手机 (< 576px) === */ -@media (max-width: 575px) { - .ant-table { - font-size: 13px; - } - .ant-table-cell { - padding: 8px 6px !important; - } - .ant-descriptions-item-label, - .ant-descriptions-item-content { - font-size: 13px; - } - .ant-modal { - max-width: calc(100vw - 24px) !important; - margin: 12px auto !important; - } - .ant-modal .ant-modal-body { - max-height: 60vh; - overflow-y: auto; - } - h2 { - font-size: 18px !important; - } - .ant-card { - margin-bottom: 8px; - } - .ant-space-item .ant-btn { - padding: 2px 6px; - font-size: 12px; - } -} - -/* === 平板 (576-991px) === */ -@media (min-width: 576px) and (max-width: 991px) { - .ant-modal { - max-width: calc(100vw - 48px) !important; - } -} -``` - -**Changed from current:** -- 旧断点 `(max-width: 767px)` 改为 `(max-width: 575px)` -- 新增 `.ant-table-wrapper { overflow-x: auto }` 通用规则 -- 新增平板断点 `(min-width: 576px) and (max-width: 991px)` 仅做弹窗微调 -- 桌面(>= 992px)使用浏览器默认样式,无需显式 @media - -- [x] **Step 2: 验证 CSS 语法** - -```bash -# 用 node 检查语法(没有专门的 CSS 语法检查,可跳过) -echo "CSS rules updated" -``` - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/index.css -git commit -m "feat: 建立三断点 CSS 体系,替换单断点移动端样式" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 2: MainLayout 三端布局重构 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/layouts/MainLayout.tsx` - -**Interfaces:** -- Consumes: antd `Grid.useBreakpoint()` (from `antd`) -- Produces: `isMobile` / `isTablet` / `isDesktop` 三端布尔值,Sider/Drawer 切换逻辑 -- Removes: `useState(window.innerWidth < 768)` + `useEffect` resize 事件监听 - -**Description:** 将 MainLayout 从自建 resize 监听改为 antd v6 的 Grid.useBreakpoint() hook,根据三端断点控制侧栏(桌面 Sider / 手机 Drawer / 平板 Sider 折叠)、Header 用户区域文字显示、Content padding。 - -- [x] **Step 1: 替换 import 和状态声明** - -找到文件中的 import 区域(第 3 行附近),在 antd 的 import 中添加 `Grid`: - -```typescript -import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid } from 'antd'; -``` - -然后替换第 86-98 行的状态和 useEffect: - -```typescript -const MainLayout: React.FC = () => { - const [collapsed, setCollapsed] = useState(false); - const screens = Grid.useBreakpoint(); - const isMobile = !screens.sm; // < 576px (仅 xs) - const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px - const isDesktop = !!screens.lg; // >= 992px - const [drawerOpen, setDrawerOpen] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - const user = JSON.parse(localStorage.getItem('user') || '{}'); - const { hasPermission } = usePermission(); -``` - -移除原来的 `useEffect` + `resize` 事件监听(第 94-98 行)和 `useState(window.innerWidth < 768)`(第 87 行)。 - -- [x] **Step 2: 更新 Sider/平板/Drawer 渲染逻辑** - -替换第 150-187 行的侧栏区域: - -```typescript - return ( - - {/* 桌面 + 平板:Sider;手机:Drawer */} - {!isMobile && ( - -
- {(isTablet || collapsed) ? '恭' : '恭学教育基地'} -
- {menuContent} -
- )} - {isMobile && ( - setDrawerOpen(false)} - width={240} - styles={{ body: { padding: 0 } }} - title="恭学教育基地" - > - {menuContent} - - )} -``` - -关键变化:平板 (`isTablet`) 时 Sider 强制折叠 (`collapsed={isTablet ? true : collapsed}`)。 - -- [x] **Step 3: 更新 Collapse 按钮逻辑** - -替换第 200-212 行的 Button onClick: - -```typescript -
- - } /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - - -``` - -变化:`span={6}` -> `xs={12} sm={12} md={6}`;Row 的 `gutter={16}` -> `gutter={[16, 16]}` 增加垂直间距。 - -- [x] **Step 3: 图表卡片 Col 改为响应式断点** - -替换第 227-246 行的图表 Row: - -```typescript - - - - {expenseStats.length > 0 ? ( - - ) : ( -
暂无费用数据
- )} -
- - - - {roomRanking.length > 0 ? ( - - ) : ( -
暂无费用数据
- )} -
- - -``` - -变化: -- `span={12}` -> `xs={24} sm={12}`(手机堆叠,平板及以上并排) -- ECharts style 添加 `width: '100%'` 确保跟随容器 resize -- 高度改为 `isMobile ? 250 : 300` - -- [x] **Step 4: 甘特图 ECharts 添加 width: '100%'** - -替换第 216-225 行的甘特图区域: - -```typescript - - {ganttData.length > 0 ? ( - - ) : ( -
暂无入住数据
- )} -
-``` - -- [x] **Step 5: 顶部工具栏小屏堆叠** - -替换第 162-177 行的标题和 DatePicker 区域: - -```typescript -
-

数据面板

- { - if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); - }} - /> -
-``` - -变化:小屏下 `flexDirection: 'column'` + `alignItems: 'flex-start'` + `gap: 12`。 - -- [x] **Step 6: Commit** - -```bash -git add apps/admin/src/pages/Dashboard/index.tsx -git commit -m "feat: Dashboard 响应式网格,统计卡片/图表/工具栏适配三端" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 4: 学生管理字段拆分和响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Students/index.tsx` - -**Interfaces:** -- Consumes: 后端 `/students` API 返回学生对象(含 `studentNumber` 可选字段) -- Produces: 拆分后的列定义、表格 scroll、工具栏 wrap - -**Description:** 将「学号/身份证」列拆分为「学号」和「身份证」两个独立列,添加 `ellipsis` 和 `width`;表格添加 `scroll={{ x }}`;顶部工具栏添加 `flexWrap: 'wrap'` + `gap`;表单中添加 `studentNumber` 字段。 - -- [x] **Step 1: 拆分列定义** - -替换第 151-156 行的 columns 数组中的对应条目。将: - -```typescript - { title: '学号/身份证', dataIndex: 'idNumber' }, -``` - -替换为: - -```typescript - { - title: '学号', - dataIndex: 'studentNumber', - width: 120, - ellipsis: true, - render: (v: string) => v || '-', - }, - { - title: '身份证', - dataIndex: 'idNumber', - width: 180, - ellipsis: true, - render: (v: string) => v || '-', - }, -``` - -同时为其余缺少 `ellipsis` 的文本列添加 `ellipsis: true`(name, phone, emergencyContact, emergencyPhone, supervisor): - -```typescript - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '姓名', dataIndex: 'name', ellipsis: true }, - { title: '性别', dataIndex: 'gender', width: 60 }, - { title: '电话', dataIndex: 'phone', ellipsis: true }, - // ... 上面已替换的学号和身份证列 - { title: '民族', dataIndex: 'ethnicity', width: 80 }, - { title: '紧急联系人', dataIndex: 'emergencyContact', ellipsis: true }, - { title: '紧急联系人电话', dataIndex: 'emergencyPhone', ellipsis: true }, - { - title: '所属机构', - dataIndex: 'organization', - render: (v: string) => (v ? {v} : '-'), - }, - { title: '负责人', dataIndex: 'supervisor', ellipsis: true }, -``` - -- [x] **Step 2: 表格添加 scroll** - -在第 305 行的 ` `共 ${total} 人` }} -``` - -- [x] **Step 3: 工具栏添加 wrap 和 gap** - -替换第 223-304 行的工具栏 JSX。在顶部 `
` 和左右 `` 之间改为: - -```typescript -
- - -
-``` - -变化: -- 外层 div 添加 `flexWrap: 'wrap'` 和 `gap: 8` -- 左右 Space 均添加 `wrap` 属性 - -- [x] **Step 4: 表单中添加学号字段** - -在 modal 中的 `` 区域(第 329 行附近),在 `name` 字段和 `gender` 字段之间添加学号字段: - -```typescript - - - -``` - -- [x] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Students/index.tsx -git commit -m "feat: 学生管理字段拆分(学号/身份证分列),表格和工具栏响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 5: 宿舍总览 RoomVisual 复查 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/RoomVisual/index.tsx` - -**Interfaces:** -- Consumes: 当前已有的 `xs/sm/md/lg` Col 响应式断点 -- Produces: 确保平板断点(md: >= 768px)覆盖 576-991px 范围 - -**Description:** RoomVisual 已经使用了 `xs={12} sm={8} md={6} lg={4}` 等响应式 Col props,只需确认布局在 576-991px 平板区域表现合理。弹窗已使用 `width={500}`,全局 CSS 会覆盖窄屏。 - -- [x] **Step 1: 验证当前布局** - -查看房态网格行(第 112 行附近),当前为 `xs={12} sm={8} md={6} lg={4}`: -- 手机 (xs: < 576px): 2 列 -- OK -- sm (>= 576px): 3 列 -- OK -- md (>= 768px): 4 列 -- OK -- lg (>= 992px): 6 列 -- OK - -平板区域(576-991px)覆盖了 sm 和 md 两个断点,从 3 列到 4 列,布局合理。无需修改。 - -- [x] **Step 2: 统计卡片栏复查** - -第 87-107 行的统计卡片当前为 `xs={12} sm={6}`: -- 手机: 2 列 -- 平板及以上 (>= 576px): 4 列 - -在平板下 4 张卡并排可能略挤,改为 `xs={12} sm={6} md={6}` 以保证桌面端也是 4 列,平板端保持 4 列(可用)。无需修改。 - -- [x] **Step 3: 弹窗复查** - -详情弹窗(第 182-249 行)`width={500}`,在手机下由全局 CSS `max-width: calc(100vw - 24px)` 覆盖。无需修改。 - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/RoomVisual/index.tsx -git commit -m "chore: RoomVisual 三端断点复查确认,无改动" -``` - -注:如果复查无改动,可跳过此 commit,或提交一个空 commit 标记完成。 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 6: 入住管理 Occupancies 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Occupancies/index.tsx` - -**Interfaces:** -- Consumes: 已有 Alert、Tab 切换区、搜索/操作区 -- Produces: 表格 scroll、工具栏 wrap、弹窗宽度(全局 CSS 覆盖) - -**Description:** 表格添加 `scroll={{ x }}`;顶部 Alert + Tab + 搜索/操作区支持小屏堆叠;弹窗宽度由全局 CSS 约束。 - -- [x] **Step 1: 表格添加 scroll** - -在第 444 行的 ` `共 ${total} 条` }} - rowSelection={rowSelection} - /> -``` - -- [x] **Step 2: 工具栏 wrap 验证** - -查看第 251-395 行的工具栏 JSX。外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。左侧 Space 有 `wrap`,右侧 Space 有 `wrap`。这些已经正确,无需修改。 - -- [x] **Step 3: 导入时自动收押金设置行检查** - -第 380-393 行的收押金设置行已经是内联 flex,在小屏下会自动换行。当前实现使用 `` 包裹,换行体验一般但可用。无需修改。 - -- [x] **Step 4: 弹窗宽度** - -当前弹窗: -- 入住登记: `width={500}`(第 460 行) -- 退宿: 无 width 属性(第 517 行,默认 520px) -- 批量退宿: `width={500}`(第 557 行) -- 换房: `width={500}`(第 610 行) - -这些弹窗在小屏下由全局 CSS `@media (max-width: 575px)` 的 `.ant-modal { max-width: calc(100vw - 24px) !important }` 约束。平板弹窗由 `@media (min-width: 576px) and (max-width: 991px)` 约束为 `max-width: calc(100vw - 48px)`。无需额外修改。 - -- [x] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Occupancies/index.tsx -git commit -m "feat: 入住管理表格添加 scroll,工具栏/弹窗响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 7: 宿舍管理 Rooms 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Rooms/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选/按钮工具栏 -- Produces: 表格 scroll、工具栏 wrap 和 gap - -**Description:** 表格添加 `scroll={{ x }}`;确保工具栏和操作列在三端适配。 - -- [x] **Step 1: 表格添加 scroll** - -在第 374 行的 ` `共 ${total} 间` }} - rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} - rowSelection={{ - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys as number[]), - getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }), - }} - /> -``` - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -查看第 279-372 行的工具栏 JSX。外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。左右 Space 均已有 `wrap`。这些已经正确,无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Rooms/index.tsx -git commit -m "feat: 宿舍管理表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 8: 费用录入 Expenses 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Expenses/index.tsx` - -**Interfaces:** -- Consumes: Tabs 的两个 Tab(宿舍费用/个人附加费),各有独立 Table 和工具栏 -- Produces: 两个表格分别添加 scroll、弹窗宽度(全局 CSS) - -**Description:** 宿舍费用和个人附加费两个 Tab 下的表格均添加 `scroll={{ x }}`;弹窗由全局 CSS 覆盖。 - -- [x] **Step 1: 宿舍费用表格添加 scroll** - -在第 435 行的宿舍费用 ` `共 ${total} 条` }} - rowSelection={{ - selectedRowKeys: selectedRoomKeys, - onChange: (keys) => setSelectedRoomKeys(keys as number[]), - }} - /> -``` - -- [x] **Step 2: 个人附加费表格添加 scroll** - -在第 587 行的个人附加费 ` `共 ${total} 条` }} - rowSelection={{ - selectedRowKeys: selectedPersonalKeys, - onChange: (keys) => setSelectedPersonalKeys(keys as number[]), - }} - /> -``` - -- [x] **Step 3: 工具栏 wrap 验证** - -第 319-326 行和第 455-461 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。Space 均有 `wrap`。无需修改。 - -- [x] **Step 4: 弹窗验证** - -录入宿舍费用弹窗(第 604 行)和个人附加费弹窗(第 644 行)均无显式 width,由全局 CSS 约束窄屏。无需修改。 - -- [x] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Expenses/index.tsx -git commit -m "feat: 费用录入两个表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 9: 押金管理 Deposits 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Deposits/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选工具栏 -- Produces: 表格 scroll、工具栏 wrap、弹窗宽度 - -**Description:** 表格添加 `scroll={{ x }}`;搜索/筛选工具栏 wrap;押金收取和退还弹窗由全局 CSS 覆盖。 - -- [x] **Step 1: 表格添加 scroll** - -在第 211 行的 ` `共 ${total} 条` }} - /> -``` - -列数较多(学生、金额、日期、状态、退还金额、扣除金额、原因、日期、备注 + 操作),使用 `x: 1000`。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 165-172 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Deposits/index.tsx -git commit -m "feat: 押金管理表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 10: 账单管理 Bills 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Bills/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选/按钮工具栏,详情弹窗含嵌套 Table -- Produces: 表格 scroll、工具栏 wrap、弹窗宽度 - -**Description:** 表格添加 `scroll={{ x }}`;搜索/筛选/按钮工具栏已有 wrap;生成账单弹窗和详情弹窗由全局 CSS 覆盖。 - -- [x] **Step 1: 主表格添加 scroll** - -在第 388 行的 ` `共 ${total} 条` }} - rowSelection={{ - selectedRowKeys: selectedRows, - onChange: (keys) => setSelectedRows(keys as number[]), - }} - /> -``` - -账单列数多(学生、周期、分摊费、个人费、总计、可用押金、抵扣后应付、状态、时间 + 操作列宽320),使用 `x: 1200`。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 307-314 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: 详情弹窗中的内嵌表格添加 scroll** - -在第 494 行的详情弹窗内嵌 ` typeMap[v] || v }, - { title: '说明', dataIndex: 'description' }, - { - title: '计费天数', - dataIndex: 'days', - render: (v: number) => (v > 0 ? `${v}天` : '-'), - }, - { - title: '宿舍总人天', - dataIndex: 'totalRoomDays', - render: (v: number) => (v > 0 ? `${v}天` : '-'), - }, - { - title: '宿舍总费用', - dataIndex: 'roomTotalAmount', - render: (v: number) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '应分摊', - dataIndex: 'studentAmount', - render: (v: number) => ¥{Number(v).toFixed(2)}, - }, - ]} - /> -``` - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/Bills/index.tsx -git commit -m "feat: 账单管理表格添加 scroll,详情内嵌表格也添加 scroll" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 11: 教室管理 Classrooms 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Classrooms/index.tsx` - -**Interfaces:** -- Consumes: 已有搜索/筛选工具栏 -- Produces: 表格 scroll - -**Description:** 表格添加 `scroll={{ x }}`;工具栏已有 wrap。 - -- [x] **Step 1: 表格添加 scroll** - -在第 265 行的 ` `共 ${total} 条` }} - /> -``` - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 194-201 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Classrooms/index.tsx -git commit -m "feat: 教室管理表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 12: 租赁订单 ClassroomRentals 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/ClassroomRentals/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 1200 }}` -- Produces: 弹窗宽度 - -**Description:** 表格已有 `scroll={{ x: 1200 }}`,值合理无需修改;工具栏已有 wrap 和 gap;弹窗 `width={600}` 由全局 CSS 覆盖窄屏。 - -- [x] **Step 1: 复查现有 scroll 值** - -第 318 行已有 `scroll={{ x: 1200 }}`。该表有 10+ 列(教室、租赁方、开始日期、结束日期、时长、日租金、总额、合同、操作),`x: 1200` 合理。无需修改。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 277-284 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`,Space 有 `wrap`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/ClassroomRentals/index.tsx -git commit -m "chore: ClassroomRentals 复查 scroll 和 wrap,确认无需修改" -``` - -注:如果复查无改动,可跳过此 commit。 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 13: 租赁方 Tenants 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Tenants/index.tsx` - -**Interfaces:** -- Consumes: 已有工具栏 -- Produces: 表格 scroll - -**Description:** 表格添加 `scroll={{ x }}`;工具栏已有 wrap 和 gap。 - -- [x] **Step 1: 表格添加 scroll** - -在第 183 行的 ` `共 ${total} 条` }} - /> -``` - -列较少(名称、联系人、电话、颜色、备注 + 操作),使用 `x: 700`。 - -- [x] **Step 2: 工具栏已有 wrap 和 gap** - -第 152-159 行的工具栏外层 div 已有 `flexWrap: 'wrap'` 和 `gap: 8`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Tenants/index.tsx -git commit -m "feat: 租赁方表格添加 scroll 横向滚动" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 14: 操作日志 OperationLogs 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/OperationLogs/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 1000 }}` 和工具栏 -- Produces: 确认现有适配 - -**Description:** 表格已有 `scroll={{ x: 1000 }}` 且值合理(8 列),无需修改;工具栏已有 wrap 和 gap。 - -- [x] **Step 1: 复查现有 scroll 值** - -第 151 行已有 `scroll={{ x: 1000 }}`。该表有 8 列(时间、操作人、模块、操作、状态、详情、IP、终端),`x: 1000` 合理。无需修改。 - -- [x] **Step 2: 工具栏已有 flexWrap 和 gap** - -第 113-121 行已有 `flexWrap: 'wrap'` 和 `gap: 12`。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/OperationLogs/index.tsx -git commit -m "chore: OperationLogs 复查 scroll 和 wrap,确认无需修改" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 15: 角色管理 Roles 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Roles/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 800 }}`、工具栏、权限分配弹窗 -- Produces: 工具栏 wrap、弹窗宽度 - -**Description:** 表格已有 `scroll={{ x: 800 }}`;工具栏添加 wrap;权限分配弹窗 `width={700}` 由全局 CSS 覆盖窄屏。 - -- [x] **Step 1: 工具栏添加 flexWrap 和 gap** - -替换第 206-212 行的工具栏 div: - -```typescript -
-

角色管理

- 新增角色 -
-``` - -- [x] **Step 2: 复查表格 scroll** - -第 224 行已有 `scroll={{ x: 800 }}`。该表有 ID、名称、描述、权限标签、系统 + 操作列(fixed: right),`x: 800` 合理。无需修改。 - -- [x] **Step 3: 弹窗宽度** - -权限分配弹窗第 238 行 `width={700}`,在窄屏下由全局 CSS 覆盖。无需修改。 - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/Roles/index.tsx -git commit -m "feat: 角色管理工具栏添加 flexWrap 响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 16: 权限一览 Permissions 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Permissions/index.tsx` - -**Interfaces:** -- Consumes: 卡片列表(非 Table 页面) -- Produces: 工具栏 wrap - -**Description:** 权限一览是卡片列表(非表格),只需确保标题 + 搜索栏在小屏下堆叠。 - -- [x] **Step 1: 工具栏添加 flexWrap 和 gap** - -替换第 58-64 行的工具栏 div: - -```typescript -
-

权限一览

- -
-``` - -- [x] **Step 2: Commit** - -```bash -git add apps/admin/src/pages/Permissions/index.tsx -git commit -m "feat: 权限一览工具栏添加 flexWrap 响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 17: 账号管理 Users 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Users/index.tsx` - -**Interfaces:** -- Consumes: 已有 `scroll={{ x: 1000 }}` -- Produces: 工具栏 wrap - -**Description:** 表格已有 `scroll={{ x: 1000 }}`;工具栏添加 wrap。 - -- [x] **Step 1: 工具栏添加 flexWrap 和 gap** - -替换第 198-204 行的工具栏 div: - -```typescript -
-

账号管理

- 新增账号 -
-``` - -- [x] **Step 2: 复查表格 scroll** - -第 216 行已有 `scroll={{ x: 1000 }}`。该表有 ID、用户名、姓名、角色、状态、最后登录、创建时间 + 操作列(fixed: right),`x: 1000` 合理。无需修改。 - -- [x] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Users/index.tsx -git commit -m "feat: 账号管理工具栏添加 flexWrap 响应式适配" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 18: 教室排期 ClassroomSchedule 特殊适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/ClassroomSchedule/index.tsx` - -**Interfaces:** -- Consumes: 已有 `overflowX: 'auto'` 容器、sticky 首列、minWidth 日期列 -- Produces: 确认日期列不被挤压,sticky 列在小屏正常 - -**Description:** 教室排期是自定义 HTML `
`,含 31+ 日期列 + sticky 首列。当前外层 div 已有 `overflowX: 'auto'`,日期列 `minWidth: 26`,首列 sticky。确认在平板/手机下正常工作。 - -- [x] **Step 1: 日期列 minWidth 不变** - -第 234 行已设置 `minWidth: 26`,在窄屏下不会挤压。无需修改。 - -- [x] **Step 2: 首列 sticky 左偏移确认** - -第 210 行首列已有 `position: 'sticky', left: 0`,在小屏下仍然生效。无需修改。 - -- [x] **Step 3: 详情弹窗宽度** - -第 327 行弹窗 `width={500}`,在窄屏下由全局 CSS 覆盖。无需修改。 - -- [x] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/ClassroomSchedule/index.tsx -git commit -m "chore: ClassroomSchedule 复查 sticky 列和日期列 minWidth,确认无需修改" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 19: 登录页 Login 响应式适配 - -**Files:** -- Modify: `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Login/index.tsx` - -**Interfaces:** -- Consumes: 已有 Card 登录表单 -- Produces: 卡片宽度改为 maxWidth + calc - -**Description:** 登录卡片当前 `width: 400`,改为 `maxWidth: 400, width: 'calc(100vw - 48px)'` 以在手机下自适应。 - -- [x] **Step 1: 卡片宽度响应式** - -替换第 39-45 行的 Card style: - -```typescript - -``` - -变化:`width: 400` -> `maxWidth: 400, width: 'calc(100vw - 48px)'` - -- [x] **Step 2: Commit** - -```bash -git add apps/admin/src/pages/Login/index.tsx -git commit -m "feat: 登录卡片改为 maxWidth + calc 响应式宽度" -``` - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Task 20: 全局验证与编译检查 - -**Files:** -- No specific file modifications; validation only - -**Interfaces:** -- Consumes: 所有已完成的页面改动 -- Produces: 验证报告(视觉 + 编译) - -**Description:** 在 Chrome DevTools 响应式模式下手动检查所有 16 个页面在三端视口宽度下的表现,确认无溢出、弹窗不出屏、表格可横向滚动。最后运行 `npm run build` 确保 TypeScript 编译通过。 - -- [x] **Step 1: Chrome DevTools 响应式模式视觉检查** - -打开浏览器访问 `http://localhost:5173`(或部署地址),使用 Chrome DevTools 的设备工具栏切换以下宽度: - -| 宽度 | 端侧 | 检查要点 | -|------|------|---------| -| 375px | 手机 | 表格可横向滚动、按钮不溢出、弹窗不出屏、文字不截断 | -| 768px | 平板 | Sider 默认折叠、Content padding 为 16px、卡片网格正确 | -| 992px | 桌面小屏 | Sider 可展开、Content padding 为 24px | -| 1440px | 桌面大屏 | 全部正常,无奇怪空白 | - -逐页检查(16 个页面): -1. /dashboard -2. /room-visual -3. /students -4. /rooms -5. /occupancies -6. /expenses -7. /deposits -8. /bills -9. /classrooms -10. /classroom-rentals -11. /tenants -12. /classroom-schedule -13. /operation-logs -14. /roles -15. /permissions -16. /users -17. /login - -每个页面检查项: -- [x] 表格有无横向滚动条(在窄屏下出现,且不影响行选择/操作) -- [x] 按钮组是否错位/溢出容器 -- [x] 弹窗(打开任意新增/编辑弹窗)是否超出屏幕边界 -- [x] 统计卡片/网格是否按预期列数排列 -- [x] ECharts 图表是否正确跟随容器宽度 -- [x] 文字是否正常截断(ellipsis 生效) - -- [x] **Step 2: TypeScript 编译检查** - -```bash -cd /Users/tiku1/code/gongxue-base -npm run build -``` - -预期:`tsc -b && vite build` 无错误输出,构建成功。 - -如果有 TypeScript 错误,修复后再执行: - -```bash -cd /Users/tiku1/code/gongxue-base/apps/admin -npx tsc -b --noEmit -``` - -- [x] **Step 3: Commit** - -```bash -git add -A -git commit -m "chore: 全局响应式适配验证完成" -``` - -如果验证中发现问题需要修复,则在对应页面进行修复后单独 commit。 - -archived-with: 2026-07-03-admin-responsive-adaptation ---- - -### Critical Files for Implementation -- `/Users/tiku1/code/gongxue-base/apps/admin/src/index.css` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/layouts/MainLayout.tsx` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Dashboard/index.tsx` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Students/index.tsx` -- `/Users/tiku1/code/gongxue-base/apps/admin/src/pages/Occupancies/index.tsx` diff --git a/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md b/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md deleted file mode 100644 index 85e125e..0000000 --- a/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md +++ /dev/null @@ -1,1802 +0,0 @@ -# 恭学教育 P0 批次 — 实现计划 - -> **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:** 实现班级管理、排课管理、宿舍/入住/账单增强、操作日志全量接入、RBAC 扩展、考勤管理前端、数据面板增强(P0 + 部分 P1)。 - -**Architecture:** Monorepo (Turborepo),后端 NestJS 11 + TypeORM 0.3,前端 React 19 + Ant Design 6。新增 `classes`/`schedules` 两个 NestJS 模块,在现有 `rooms`/`occupancies`/`bills` 模块增量增强,考勤前端新建。 - -**Tech Stack:** NestJS 11, TypeORM 0.3, SQLite/MySQL, React 19, Ant Design 6, ECharts, class-validator, Jest, Playwright - -## Global Constraints - -- 表名使用复数形式(与现有 entities 一致:`students`、`rooms`、`classrooms`、`bills`) -- Entity 使用 `@Entity('table_name')` + `@Column({ name: 'snake_case' })` 模式,无 BaseEntity 继承 -- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 -- Module 必须 `imports: [TypeOrmModule.forFeature([...]), OperationLogsModule]` -- Controller 所有方法 `@UseGuards(JwtAuthGuard)` + `@RequirePermission('...')` -- 所有写操作调用 `OperationLogsService.log()`,使用 `extractRequestInfo(req)` 获取 IP/UA -- DTO 使用 class-validator 装饰器,分 CreateDto / UpdateDto -- 前端 axios 实例从 `api/` 导入,`api.get/post/put/delete` 自动解包 `response.data` -- 前端新增路由在 `App.tsx` 注册,包裹 `PermissionRoute` + `permission` 属性 -- 前端敏感操作按钮使用 `PermissionButton` 组件 - ---- - -## Phase 1: 班级管理实体与模块 - -### Task 1.1: 创建 Class 实体 - -**Files:** -- Create: `apps/server/src/entities/class.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -**Interfaces:** -- Produces: `Class` entity class — exports for TypeORM `@Entity('classes')` - -- [ ] **Step 1: 创建 class.entity.ts** - -```typescript -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - OneToMany, - JoinColumn, -} from 'typeorm'; -import { Department } from './department.entity'; -// forward-ref relations will be added after child entities exist - -export enum ClassType { - CULTURE = 'culture', - PROFESSIONAL = 'professional', - BOOTCAMP = 'bootcamp', - SPRINT = 'sprint', -} - -export enum ClassStatus { - ENROLLING = 'enrolling', - ACTIVE = 'active', - ENDED = 'ended', - SUSPENDED = 'suspended', -} - -@Entity('classes') -export class Class { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'name', length: 100 }) - name: string; - - @Column({ name: 'code', length: 50, unique: true }) - code: string; - - @Column({ name: 'department_id', type: 'integer', nullable: true }) - departmentId: number; - - @ManyToOne('Department', { nullable: true }) - @JoinColumn({ name: 'department_id' }) - department: any; - - @Column({ name: 'class_type', length: 20 }) - classType: string; - - @Column({ name: 'start_date', type: 'date', nullable: true }) - startDate: string; - - @Column({ name: 'end_date', type: 'date', nullable: true }) - endDate: string; - - @Column({ name: 'status', length: 20, default: ClassStatus.ENROLLING }) - status: string; - - @Column({ name: 'head_teacher_id', type: 'integer', nullable: true }) - headTeacherId: number; - - @Column({ name: 'life_teacher_id', type: 'integer', nullable: true }) - lifeTeacherId: number; - - @Column({ name: 'academic_teacher_id', type: 'integer', nullable: true }) - academicTeacherId: number; - - @Column({ name: 'max_students', type: 'integer', default: 0 }) - maxStudents: number; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} -``` - -- [ ] **Step 2: 在 entities/index.ts 中注册导出** - -在 `apps/server/src/entities/index.ts` 中添加: -```typescript -export { Class, ClassType, ClassStatus } from './class.entity'; -``` - -- [ ] **Step 3: 创建 class-student.entity.ts** - -```typescript -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Unique, -} from 'typeorm'; -import { Class } from './class.entity'; -import { Student } from './student.entity'; - -@Entity('class_student') -@Unique(['classId', 'studentId']) -export class ClassStudent { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'class_id', type: 'integer' }) - classId: number; - - @ManyToOne(() => Class, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'class_id' }) - class: Class; - - @Column({ name: 'student_id', type: 'integer' }) - studentId: number; - - @ManyToOne(() => Student) - @JoinColumn({ name: 'student_id' }) - student: Student; - - @Column({ name: 'enrollment_id', type: 'integer', nullable: true }) - enrollmentId: number; - - @Column({ name: 'join_date', type: 'date', nullable: true }) - joinDate: string; - - @Column({ name: 'leave_date', type: 'date', nullable: true }) - leaveDate: string; - - @Column({ name: 'status', length: 10, default: 'active' }) - status: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 4: 在 entities/index.ts 中注册** - -```typescript -export { ClassStudent } from './class-student.entity'; -``` - -- [ ] **Step 5: 创建 class-teacher.entity.ts** - -```typescript -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Unique, -} from 'typeorm'; -import { Class } from './class.entity'; -import { User } from './user.entity'; - -export enum TeacherRoleType { - SUBJECT_TEACHER = 'subject_teacher', - HEAD_TEACHER = 'head_teacher', - LIFE_TEACHER = 'life_teacher', - ACADEMIC_TEACHER = 'academic_teacher', -} - -@Entity('class_teacher') -@Unique(['classId', 'userId', 'roleType']) -export class ClassTeacher { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'class_id', type: 'integer' }) - classId: number; - - @ManyToOne(() => Class, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'class_id' }) - class: Class; - - @Column({ name: 'user_id', type: 'integer' }) - userId: number; - - @ManyToOne(() => User) - @JoinColumn({ name: 'user_id' }) - user: User; - - @Column({ name: 'role_type', length: 30 }) - roleType: string; - - @Column({ name: 'subject', length: 50, nullable: true }) - subject: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 6: 在 entities/index.ts 中注册** - -```typescript -export { ClassTeacher, TeacherRoleType } from './class-teacher.entity'; -``` - -- [ ] **Step 7: 验证 — 启动后端检查 TypeORM 自动建表** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,`classes`/`class_student`/`class_teacher` 三张表自动创建。 - -- [ ] **Step 8: Commit** - -```bash -git add apps/server/src/entities/ -git commit -m "feat: add Class, ClassStudent, ClassTeacher entities" -``` - ---- - -### Task 1.2: 创建 Classes NestJS 模块 — DTO + Service - -**Files:** -- Create: `apps/server/src/classes/dto/class.dto.ts` -- Create: `apps/server/src/classes/classes.service.ts` -- Create: `apps/server/src/classes/classes.module.ts` - -**Interfaces:** -- Consumes: `Class`, `ClassStudent`, `ClassTeacher` entities from Task 1.1 -- Produces: `ClassesService` with methods: `findAll`, `findOne`, `create`, `update`, `remove`, `getStudents`, `addStudents`, `removeStudent`, `getTeachers`, `addTeacher`, `removeTeacher` - -- [ ] **Step 1: 创建 DTO** - -```typescript -// apps/server/src/classes/dto/class.dto.ts -import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum, IsArray, IsDateString } from 'class-validator'; - -export class CreateClassDto { - @IsString() @IsNotEmpty() - name: string; - - @IsString() @IsNotEmpty() - code: string; - - @IsOptional() @IsInt() - departmentId?: number; - - @IsString() @IsNotEmpty() - classType: string; - - @IsOptional() @IsDateString() - startDate?: string; - - @IsOptional() @IsDateString() - endDate?: string; - - @IsOptional() @IsString() - status?: string; - - @IsOptional() @IsInt() - headTeacherId?: number; - - @IsOptional() @IsInt() - lifeTeacherId?: number; - - @IsOptional() @IsInt() - academicTeacherId?: number; - - @IsOptional() @IsInt() - maxStudents?: number; - - @IsOptional() @IsString() - notes?: string; - - @IsOptional() @IsArray() - studentIds?: number[]; - - @IsOptional() @IsArray() - teachers?: Array<{ userId: number; roleType: string; subject?: string }>; -} - -export class UpdateClassDto { - @IsOptional() @IsString() - name?: string; - - @IsOptional() @IsString() - code?: string; - - @IsOptional() @IsInt() - departmentId?: number; - - @IsOptional() @IsString() - classType?: string; - - @IsOptional() @IsDateString() - startDate?: string; - - @IsOptional() @IsDateString() - endDate?: string; - - @IsOptional() @IsString() - status?: string; - - @IsOptional() @IsInt() - headTeacherId?: number; - - @IsOptional() @IsInt() - lifeTeacherId?: number; - - @IsOptional() @IsInt() - academicTeacherId?: number; - - @IsOptional() @IsInt() - maxStudents?: number; - - @IsOptional() @IsString() - notes?: string; -} - -export class QueryClassDto { - @IsOptional() @IsInt() - departmentId?: number; - - @IsOptional() @IsString() - status?: string; - - @IsOptional() @IsString() - classType?: string; - - @IsOptional() @IsString() - keyword?: string; -} - -export class AddStudentsDto { - @IsArray() @IsInt({ each: true }) - studentIds: number[]; -} - -export class AddTeacherDto { - @IsInt() - userId: number; - - @IsString() - roleType: string; - - @IsOptional() @IsString() - subject?: string; -} -``` - -- [ ] **Step 2: 创建 Service** - -```typescript -// apps/server/src/classes/classes.service.ts -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, Like } from 'typeorm'; -import { Class, ClassStudent, ClassTeacher } from '../entities'; - -@Injectable() -export class ClassesService { - constructor( - @InjectRepository(Class) - private classRepo: Repository, - @InjectRepository(ClassStudent) - private classStudentRepo: Repository, - @InjectRepository(ClassTeacher) - private classTeacherRepo: Repository, - ) {} - - async findAll(query: { departmentId?: number; status?: string; classType?: string; keyword?: string }) { - const where: any = {}; - if (query.departmentId) where.departmentId = query.departmentId; - if (query.status) where.status = query.status; - if (query.classType) where.classType = query.classType; - if (query.keyword) where.name = Like(`%${query.keyword}%`); - - const classes = await this.classRepo.find({ - where, - order: { createdAt: 'DESC' }, - }); - - // count students per class - const studentCounts = await this.classStudentRepo - .createQueryBuilder('cs') - .select('cs.class_id', 'classId') - .addSelect('COUNT(cs.id)', 'count') - .where('cs.status = :status', { status: 'active' }) - .groupBy('cs.class_id') - .getRawMany(); - - const countMap = new Map(studentCounts.map((r: any) => [Number(r.classId), Number(r.count)])); - - return classes.map((c) => ({ - ...c, - studentCount: countMap.get(c.id) || 0, - })); - } - - async findOne(id: number) { - const cls = await this.classRepo.findOne({ where: { id } }); - if (!cls) throw new NotFoundException('班级不存在'); - - const students = await this.classStudentRepo.find({ - where: { classId: id }, - relations: ['student'], - }); - const teachers = await this.classTeacherRepo.find({ - where: { classId: id }, - relations: ['user'], - }); - - return { - ...cls, - students: students.map((s) => ({ - id: s.id, - studentId: s.studentId, - studentName: (s.student as any)?.name, - studentNo: (s.student as any)?.studentNo, - joinDate: s.joinDate, - leaveDate: s.leaveDate, - status: s.status, - })), - teachers: teachers.map((t) => ({ - id: t.id, - userId: t.userId, - username: (t.user as any)?.username, - roleType: t.roleType, - subject: t.subject, - })), - studentCount: students.filter((s) => s.status === 'active').length, - }; - } - - async create(dto: any) { - const { studentIds, teachers, ...classData } = dto; - - const cls = this.classRepo.create(classData); - const saved = await this.classRepo.save(cls); - - // add students - if (studentIds?.length) { - const entries = studentIds.map((sid: number) => - this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), - ); - await this.classStudentRepo.save(entries); - } - - // add teachers - if (teachers?.length) { - const entries = teachers.map((t: any) => - this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject }), - ); - await this.classTeacherRepo.save(entries); - - // sync head/life/academic teacher IDs - await this.syncClassTeacherIds(saved.id); - } - - return this.findOne(saved.id); - } - - async update(id: number, dto: any) { - const cls = await this.classRepo.findOne({ where: { id } }); - if (!cls) throw new NotFoundException('班级不存在'); - await this.classRepo.update(id, dto); - return this.findOne(id); - } - - async remove(id: number) { - const cls = await this.classRepo.findOne({ where: { id } }); - if (!cls) throw new NotFoundException('班级不存在'); - await this.classRepo.remove(cls); - return { success: true }; - } - - async getStudents(classId: number) { - return this.classStudentRepo.find({ - where: { classId }, - relations: ['student'], - order: { createdAt: 'ASC' }, - }); - } - - async addStudents(classId: number, studentIds: number[]) { - const existing = await this.classStudentRepo.find({ - where: { classId, studentId: In(studentIds) }, - }); - const existingIds = new Set(existing.map((e) => e.studentId)); - const newIds = studentIds.filter((id) => !existingIds.has(id)); - - const entries = newIds.map((sid) => - this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), - ); - if (entries.length) await this.classStudentRepo.save(entries); - - return { added: entries.length, skipped: studentIds.length - entries.length }; - } - - async removeStudent(classId: number, studentId: number) { - await this.classStudentRepo.delete({ classId, studentId }); - return { success: true }; - } - - async getTeachers(classId: number) { - return this.classTeacherRepo.find({ - where: { classId }, - relations: ['user'], - }); - } - - async addTeacher(classId: number, dto: { userId: number; roleType: string; subject?: string }) { - const existing = await this.classTeacherRepo.findOne({ - where: { classId, userId: dto.userId, roleType: dto.roleType }, - }); - if (existing) throw new BadRequestException('该教师已分配此角色'); - - const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject }); - await this.classTeacherRepo.save(entry); - - await this.syncClassTeacherIds(classId); - return entry; - } - - async removeTeacher(classId: number, userId: number) { - await this.classTeacherRepo.delete({ classId, userId }); - await this.syncClassTeacherIds(classId); - return { success: true }; - } - - private async syncClassTeacherIds(classId: number) { - const teachers = await this.classTeacherRepo.find({ where: { classId } }); - const updates: any = {}; - const head = teachers.find((t) => t.roleType === 'head_teacher'); - const life = teachers.find((t) => t.roleType === 'life_teacher'); - const academic = teachers.find((t) => t.roleType === 'academic_teacher'); - if (head) updates.headTeacherId = head.userId; - if (life) updates.lifeTeacherId = life.userId; - if (academic) updates.academicTeacherId = academic.userId; - if (Object.keys(updates).length > 0) { - await this.classRepo.update(classId, updates); - } - } -} -``` - -- [ ] **Step 3: 创建 Module** - -```typescript -// apps/server/src/classes/classes.module.ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Class, ClassStudent, ClassTeacher } from '../entities'; -import { ClassesService } from './classes.service'; -import { ClassesController } from './classes.controller'; -import { OperationLogsModule } from '../operation-logs/operation-logs.module'; - -@Module({ - imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule], - controllers: [ClassesController], - providers: [ClassesService], - exports: [ClassesService], -}) -export class ClassesModule {} -``` - -- [ ] **Step 4: 验证 — 编译通过** - -```bash -cd apps/server && npx tsc --noEmit -``` - -Expected: 无类型错误。 - -- [ ] **Step 5: Commit** - -```bash -git add apps/server/src/classes/ -git commit -m "feat: add ClassesService with CRUD + student/teacher management" -``` - ---- - -### Task 1.3: 创建 ClassesController - -**Files:** -- Create: `apps/server/src/classes/classes.controller.ts` -- Modify: `apps/server/src/app.module.ts` - -**Interfaces:** -- Consumes: `ClassesService` from Task 1.2, `OperationLogsService` (global), `extractRequestInfo` from common -- Produces: REST endpoints matching spec API design - -- [ ] **Step 1: 创建 Controller** - -```typescript -// apps/server/src/classes/classes.controller.ts -import { - Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, -} from '@nestjs/common'; -import { ClassesService } from './classes.service'; -import { CreateClassDto, UpdateClassDto, QueryClassDto, AddStudentsDto, AddTeacherDto } from './dto/class.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; -import { RequirePermission } from '../auth/decorators/permission.decorator'; - -@UseGuards(JwtAuthGuard) -@Controller('classes') -export class ClassesController { - constructor( - private service: ClassesService, - private logService: OperationLogsService, - ) {} - - @Get() - @RequirePermission('class:view') - findAll(@Query() query: QueryClassDto) { - return this.service.findAll(query); - } - - @Get(':id') - @RequirePermission('class:view') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); - } - - @Post() - @RequirePermission('class:create') - async create(@Body() dto: CreateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '创建班级', - targetId: result.id, - targetType: 'class', - detail: `班级${dto.name}(${dto.code})`, - ipAddress, - userAgent, - }); - return result; - } - - @Put(':id') - @RequirePermission('class:edit') - async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '编辑班级', - targetId: +id, - targetType: 'class', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; - } - - @Delete(':id') - @RequirePermission('class:delete') - async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '删除班级', - targetId: +id, - targetType: 'class', - ipAddress, - userAgent, - }); - return { success: true }; - } - - @Get(':id/students') - @RequirePermission('class:view') - getStudents(@Param('id') id: string) { - return this.service.getStudents(+id); - } - - @Post(':id/students') - @RequirePermission('class:edit') - async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.addStudents(+id, dto.studentIds); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加学员', - targetId: +id, - targetType: 'class', - detail: `添加${result.added}名学员`, - ipAddress, - userAgent, - }); - return result; - } - - @Delete(':id/students/:studentId') - @RequirePermission('class:edit') - async removeStudent(@Param('id') id: string, @Param('studentId') studentId: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.service.removeStudent(+id, +studentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除学员', - targetId: +id, - targetType: 'class', - detail: `移除学员${studentId}`, - ipAddress, - userAgent, - }); - return { success: true }; - } - - @Get(':id/teachers') - @RequirePermission('class:view') - getTeachers(@Param('id') id: string) { - return this.service.getTeachers(+id); - } - - @Post(':id/teachers') - @RequirePermission('class:edit') - async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.addTeacher(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加教师', - targetId: +id, - targetType: 'class', - detail: `添加教师${dto.userId} 角色${dto.roleType}`, - ipAddress, - userAgent, - }); - return result; - } - - @Delete(':id/teachers/:userId') - @RequirePermission('class:edit') - async removeTeacher(@Param('id') id: string, @Param('userId') userId: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.service.removeTeacher(+id, +userId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除教师', - targetId: +id, - targetType: 'class', - detail: `移除教师${userId}`, - ipAddress, - userAgent, - }); - return { success: true }; - } -} -``` - -- [ ] **Step 2: 在 app.module.ts 注册模块** - -在 `apps/server/src/app.module.ts` 的 import 区域添加: -```typescript -import { ClassesModule } from './classes/classes.module'; -``` - -在 `imports` 数组中追加: -```typescript -ClassesModule, -``` - -- [ ] **Step 3: 验证 — 启动后端测试 API** - -```bash -cd apps/server && npm run start:dev -``` - -用 curl 测试(需先获取 JWT token): -```bash -# 创建班级 -curl -X POST http://localhost:3000/api/classes \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"2026届文化课冲刺1班","code":"2026-WHK-01","classType":"sprint","maxStudents":40}' -``` - -Expected: 返回创建的班级 JSON,含 `id`、`studentCount: 0`。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/classes/classes.controller.ts apps/server/src/app.module.ts -git commit -m "feat: add ClassesController with full CRUD + student/teacher management endpoints" -``` - ---- - -### Task 1.4: 前端班级列表页 - -**Files:** -- Create: `apps/admin/src/pages/Classes/index.tsx` -- Modify: `apps/admin/src/App.tsx` -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -- [ ] **Step 1: 创建列表页** - -```tsx -// apps/admin/src/pages/Classes/index.tsx -import React, { useEffect, useState, useMemo } from 'react'; -import { - Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber, - DatePicker, Popconfirm, message, Card, -} from 'antd'; -import { PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import dayjs from 'dayjs'; -import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; - -const STATUS_MAP: Record = { - enrolling: { color: 'blue', text: '招生中' }, - active: { color: 'green', text: '在读' }, - ended: { color: 'default', text: '结课' }, - suspended: { color: 'orange', text: '停课' }, -}; - -const TYPE_MAP: Record = { - culture: '文化课', - professional: '专业课', - bootcamp: '集训营', - sprint: '冲刺营', -}; - -const ClassesPage: React.FC = () => { - const navigate = useNavigate(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [searchText, setSearchText] = useState(''); - const [filterStatus, setFilterStatus] = useState(); - const [filterType, setFilterType] = useState(); - const [form] = Form.useForm(); - - const fetchData = async () => { - setLoading(true); - try { - const params: any = {}; - if (filterStatus) params.status = filterStatus; - if (filterType) params.classType = filterType; - const res: any = await api.get('/classes', { params }); - setData(res); - } catch (e) { - console.error(e); - } - setLoading(false); - }; - - useEffect(() => { fetchData(); }, [filterStatus, filterType]); - - const filtered = useMemo(() => { - if (!searchText) return data; - const q = searchText.toLowerCase(); - return data.filter((c: any) => - c.name?.toLowerCase().includes(q) || c.code?.toLowerCase().includes(q), - ); - }, [data, searchText]); - - const handleCreate = () => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }; - - const handleEdit = (record: any) => { - setEditing(record); - form.setFieldsValue({ - ...record, - startDate: record.startDate ? dayjs(record.startDate) : undefined, - endDate: record.endDate ? dayjs(record.endDate) : undefined, - }); - setModalOpen(true); - }; - - const handleSubmit = async () => { - const values = await form.validateFields(); - const payload = { - ...values, - startDate: values.startDate?.format('YYYY-MM-DD'), - endDate: values.endDate?.format('YYYY-MM-DD'), - }; - if (editing) { - await api.put(`/classes/${editing.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/classes', payload); - message.success('创建成功'); - } - setModalOpen(false); - fetchData(); - }; - - const handleDelete = async (id: number) => { - await api.delete(`/classes/${id}`); - message.success('已删除'); - fetchData(); - }; - - const columns = [ - { title: '班级名称', dataIndex: 'name', sorter: (a: any, b: any) => a.name.localeCompare(b.name) }, - { title: '编码', dataIndex: 'code', width: 140 }, - { title: '班型', dataIndex: 'classType', width: 100, render: (v: string) => {TYPE_MAP[v] || v} }, - { - title: '开班日期', dataIndex: 'startDate', width: 110, - render: (v: string) => v || '-', - }, - { - title: '学员', width: 100, - render: (_: any, r: any) => `${r.studentCount || 0}/${r.maxStudents || '-'}`, - }, - { - title: '状态', dataIndex: 'status', width: 90, - render: (v: string) => { - const cfg = STATUS_MAP[v] || { color: 'default', text: v }; - return {cfg.text}; - }, - }, - { - title: '操作', width: 200, - render: (_: any, r: any) => ( - - - handleEdit(r)}>编辑 - handleDelete(r.id)}> - 删除 - - - ), - }, - ]; - - return ( - - - } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - style={{ width: 200 }} - /> - ({ value: k, label: v.text }))} - /> - } onClick={handleCreate}> - 创建班级 - - -
- - setModalOpen(false)} - width={600} - > - - - - - - - - - ({ value: k, label: v.text }))} /> - - - - - - - - ); -}; - -export default ClassesPage; -``` - -- [ ] **Step 2: 在 App.tsx 添加路由** - -在 `apps/admin/src/App.tsx` 的 import 区添加: -```typescript -import ClassesPage from './pages/Classes'; -``` - -在 `` 内添加: -```tsx -} /> -} /> -``` - -**注意**:`ClassDetailPage` 暂时以占位组件引入,下一个任务实现。 - -- [ ] **Step 3: 在 MainLayout.tsx 添加菜单项** - -在菜单数组中加入: -```typescript -{ key: '/classes', icon: , label: '班级管理', permission: 'class:view' }, -``` - -**注意**:确保 `TeamOutlined` 已从 `@ant-design/icons` 导入。 - -- [ ] **Step 4: 验证 — 启动前端查看页面** - -```bash -cd apps/admin && npm run dev -``` - -打开浏览器访问 `/classes`,验证:表格渲染、筛选功能、创建/编辑弹窗。 - -- [ ] **Step 5: Commit** - -```bash -git add apps/admin/src/pages/Classes/index.tsx apps/admin/src/App.tsx apps/admin/src/layouts/MainLayout.tsx -git commit -m "feat: add Classes list page with CRUD modal" -``` - ---- - -### Task 1.5: 前端班级详情页 - -**Files:** -- Create: `apps/admin/src/pages/Classes/Detail.tsx` -- Modify: `apps/admin/src/App.tsx` (update import) - -- [ ] **Step 1: 创建详情页** - -```tsx -// apps/admin/src/pages/Classes/Detail.tsx -import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { - Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag, - Popconfirm, message, Form, Input, DatePicker, InputNumber, -} from 'antd'; -import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; - -const STATUS_MAP: Record = { - enrolling: { color: 'blue', text: '招生中' }, - active: { color: 'green', text: '在读' }, - ended: { color: 'default', text: '结课' }, - suspended: { color: 'orange', text: '停课' }, -}; - -const TYPE_MAP: Record = { - culture: '文化课', professional: '专业课', bootcamp: '集训营', sprint: '冲刺营', -}; - -const ROLE_MAP: Record = { - subject_teacher: '任课老师', head_teacher: '班主任', life_teacher: '生活老师', academic_teacher: '学服老师', -}; - -const ClassDetailPage: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const [detail, setDetail] = useState(null); - const [students, setStudents] = useState([]); - const [teachers, setTeachers] = useState([]); - const [loading, setLoading] = useState(false); - const [editForm] = Form.useForm(); - const [editingInfo, setEditingInfo] = useState(false); - - // Student modal state - const [studentModalOpen, setStudentModalOpen] = useState(false); - const [allStudents, setAllStudents] = useState([]); - const [selectedStudentIds, setSelectedStudentIds] = useState([]); - - // Teacher modal state - const [teacherModalOpen, setTeacherModalOpen] = useState(false); - const [allUsers, setAllUsers] = useState([]); - const [teacherRole, setTeacherRole] = useState('subject_teacher'); - const [teacherSubject, setTeacherSubject] = useState(''); - const [selectedTeacherId, setSelectedTeacherId] = useState(); - - const fetchDetail = async () => { - setLoading(true); - try { - const res: any = await api.get(`/classes/${id}`); - setDetail(res); - setStudents(res.students || []); - setTeachers(res.teachers || []); - } catch (e) { console.error(e); } - setLoading(false); - }; - - useEffect(() => { fetchDetail(); }, [id]); - - const handleSaveInfo = async () => { - const values = await editForm.validateFields(); - await api.put(`/classes/${id}`, { - ...values, - startDate: values.startDate?.format('YYYY-MM-DD'), - endDate: values.endDate?.format('YYYY-MM-DD'), - }); - setEditingInfo(false); - fetchDetail(); - message.success('已更新'); - }; - - const handleRemoveStudent = async (studentId: number) => { - await api.delete(`/classes/${id}/students/${studentId}`); - fetchDetail(); - message.success('已移除'); - }; - - const handleAddStudents = async () => { - if (!selectedStudentIds.length) return; - await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds }); - setStudentModalOpen(false); - setSelectedStudentIds([]); - fetchDetail(); - message.success('已添加'); - }; - - const handleAddTeacher = async () => { - if (!selectedTeacherId) return; - await api.post(`/classes/${id}/teachers`, { - userId: selectedTeacherId, - roleType: teacherRole, - subject: teacherSubject || undefined, - }); - setTeacherModalOpen(false); - fetchDetail(); - message.success('已添加'); - }; - - const handleRemoveTeacher = async (userId: number) => { - await api.delete(`/classes/${id}/teachers/${userId}`); - fetchDetail(); - message.success('已移除'); - }; - - const openStudentModal = async () => { - const res: any = await api.get('/students', { params: { includeArchived: 'false' } }); - setAllStudents(res || []); - setSelectedStudentIds([]); - setStudentModalOpen(true); - }; - - const openTeacherModal = async () => { - const res: any = await api.get('/users'); - setAllUsers(res || []); - setSelectedTeacherId(undefined); - setTeacherRole('subject_teacher'); - setTeacherSubject(''); - setTeacherModalOpen(true); - }; - - if (!detail) return null; - - const studentColumns = [ - { title: '姓名', dataIndex: 'studentName' }, - { title: '学号', dataIndex: 'studentNo' }, - { title: '加入日期', dataIndex: 'joinDate' }, - { - title: '状态', dataIndex: 'status', - render: (v: string) => {v === 'active' ? '在读' : '已离班'}, - }, - { - title: '操作', - render: (_: any, r: any) => ( - handleRemoveStudent(r.studentId)}> - - - ), - }, - ]; - - const teacherColumns = [ - { title: '姓名', dataIndex: 'username' }, - { - title: '角色', dataIndex: 'roleType', - render: (v: string) => {ROLE_MAP[v] || v}, - }, - { title: '科目', dataIndex: 'subject', render: (v: string) => v || '-' }, - { - title: '操作', - render: (_: any, r: any) => ( - handleRemoveTeacher(r.userId)}> - - - ), - }, - ]; - - return ( - - - - - - ) : ( -
- - {TYPE_MAP[detail.classType]} - {detail.startDate || '-'} - {detail.endDate || '-'} - {detail.studentCount}/{detail.maxStudents || '-'} - {teachers.find((t:any) => t.roleType === 'head_teacher')?.username || '-'} - {detail.notes || '-'} - - { editForm.setFieldsValue(detail); setEditingInfo(true); }}>编辑 -
- )} - - ), - }, - { - key: 'students', label: `花名册 (${students.filter((s:any) => s.status === 'active').length})`, - children: ( -
- -
- setStudentModalOpen(false)}> -
- setTeacherModalOpen(false)}> - - ({ value: k, label: v }))} - /> - {teacherRole === 'subject_teacher' && ( - setTeacherSubject(e.target.value)} /> - )} - - - - ), - }, - ]} /> - - ); -}; - -export default ClassDetailPage; -``` - -- [ ] **Step 2: 更新 App.tsx import** - -确保 `App.tsx` 中 `ClassDetailPage` 的 import 已添加(Task 1.4 中已预留路由)。 - -- [ ] **Step 3: 验证 — 浏览器测试** - -打开班级列表 → 点击"详情" → 验证基本信息/花名册/教师三个 Tab 渲染和数据加载。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/admin/src/pages/Classes/Detail.tsx apps/admin/src/App.tsx -git commit -m "feat: add Class detail page with student roster and teacher tabs" -``` - ---- - -## Phase 2: 排课管理 - -### Task 2.1: 创建 ClassSchedule 实体 - -**Files:** -- Create: `apps/server/src/entities/class-schedule.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -- [ ] **Step 1: 创建实体** - -```typescript -import { - Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, - UpdateDateColumn, ManyToOne, JoinColumn, Check, -} from 'typeorm'; - -export enum ScheduleType { - INTERNAL = 'INTERNAL', - RENTAL = 'RENTAL', -} - -@Entity('class_schedule') -@Check('week_day BETWEEN 1 AND 7') -export class ClassSchedule { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'class_id', type: 'integer' }) - classId: number; - - @ManyToOne('Class') - @JoinColumn({ name: 'class_id' }) - class: any; - - @Column({ name: 'classroom_id', type: 'integer' }) - classroomId: number; - - @ManyToOne('Classroom') - @JoinColumn({ name: 'classroom_id' }) - classroom: any; - - @Column({ name: 'week_day', type: 'integer' }) - weekDay: number; - - @Column({ name: 'start_time', length: 5 }) - startTime: string; - - @Column({ name: 'end_time', length: 5 }) - endTime: string; - - @Column({ name: 'start_date', type: 'date' }) - startDate: string; - - @Column({ name: 'end_date', type: 'date' }) - endDate: string; - - @Column({ name: 'subject', length: 50 }) - subject: string; - - @Column({ name: 'teacher_id', type: 'integer', nullable: true }) - teacherId: number; - - @ManyToOne('User', { nullable: true }) - @JoinColumn({ name: 'teacher_id' }) - teacher: any; - - @Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' }) - scheduleType: string; - - @Column({ name: 'rental_id', type: 'integer', nullable: true }) - rentalId: number; - - @Column({ name: 'status', length: 20, default: 'active' }) - status: string; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} -``` - -- [ ] **Step 2: 在 index.ts 导出** - -```typescript -export { ClassSchedule, ScheduleType } from './class-schedule.entity'; -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/entities/ -git commit -m "feat: add ClassSchedule entity" -``` - ---- - -### Task 2.2: 创建 Schedules 模块 — Service + Controller - -**Files:** -- Create: `apps/server/src/schedules/dto/schedule.dto.ts` -- Create: `apps/server/src/schedules/schedules.service.ts` -- Create: `apps/server/src/schedules/schedules.controller.ts` -- Create: `apps/server/src/schedules/schedules.module.ts` -- Modify: `apps/server/src/app.module.ts` - -Proceed to implement based on the patterns established in Phase 1 — the service handles conflict detection by querying for overlapping active schedules (same classroom, same week_day, overlapping time range), and the controller follows the same JwtAuthGuard + OperationLogsService pattern. - -Key conflict detection query: -```typescript -async checkConflict(classroomId: number, weekDay: number, startTime: string, endTime: string, excludeId?: number) { - const qb = this.scheduleRepo.createQueryBuilder('cs') - .where('cs.classroom_id = :classroomId', { classroomId }) - .andWhere('cs.week_day = :weekDay', { weekDay }) - .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.start_time < :endTime', { endTime }) - .andWhere('cs.end_time > :startTime', { startTime }); - if (excludeId) qb.andWhere('cs.id != :excludeId', { excludeId }); - return qb.getMany(); -} -``` - -The weekly view endpoint aggregates schedules by classroom and weekDay for the frontend matrix. - -- [ ] **Step 1-6: 实现 DTO, Service, Controller, Module,注册到 AppModule** - -(具体代码略,遵循 Tasks 1.2-1.3 的相同模式) - -- [ ] **Step 7: Commit** - -```bash -git add apps/server/src/schedules/ apps/server/src/app.module.ts -git commit -m "feat: add Schedules module with conflict detection and weekly view" -``` - ---- - -### Task 2.3: 前端排课周视图 - -**Files:** -- Create: `apps/admin/src/pages/Schedules/index.tsx` -- Modify: `apps/admin/src/App.tsx` -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -周视图矩阵:列 = 周一~周日,行 = 教室。每格显示科目/教师/时间。点击格子弹出排课 Modal(班级/科目/教师/教室/星期/时段/日期范围)。 - -- [ ] **Step 1-3: 实现页面、路由、菜单** - -(具体代码略,遵循 Phase 1 前端页面模式,矩阵渲染使用嵌套 Table 或 Grid) - -- [ ] **Step 4: Commit** - ---- - -## Phase 3: 宿舍/入住/账单增强(可与 Phase 1/2 并行) - -### Task 3.1: Room + Occupancy 实体增强 - -**Files:** -- Modify: `apps/server/src/entities/room.entity.ts` -- Modify: `apps/server/src/entities/occupancy.entity.ts` -- Modify: `apps/server/src/entities/student.entity.ts` -- Modify: `apps/server/src/rooms/dto/room.dto.ts` (需要查找实际文件名) -- Modify: `apps/server/src/occupancies/dto/` (需要查找实际文件名) - -- [ ] **Step 1: Room 实体增加字段** - -在 `room.entity.ts` 中添加: -```typescript -@Column({ name: 'rental_category', length: 10, default: 'short' }) -rentalCategory: string; - -@Column({ name: 'monthly_rate', type: 'decimal', precision: 10, scale: 2, default: 0 }) -monthlyRate: number; -``` - -- [ ] **Step 2: Occupancy 实体增加字段** - -在 `occupancy.entity.ts` 中添加: -```typescript -@Column({ name: 'rental_type', length: 10, default: 'short' }) -rentalType: string; - -@Column({ name: 'tenant_id', type: 'integer', nullable: true }) -tenantId: number; - -@ManyToOne(() => Tenant, { nullable: true }) -@JoinColumn({ name: 'tenant_id' }) -tenant: Tenant; -``` - -- [ ] **Step 3: Student 实体增加 tenant_id** - -在 `student.entity.ts` 中添加: -```typescript -@Column({ name: 'tenant_id', type: 'integer', nullable: true }) -tenantId: number; - -@ManyToOne(() => Tenant, { nullable: true }) -@JoinColumn({ name: 'tenant_id' }) -tenant: Tenant; -``` - -- [ ] **Step 4: 更新 Room DTO** - -在 `create-room.dto.ts` 和 `update-room.dto.ts` 中添加字段: -```typescript -@IsOptional() @IsString() -rentalCategory?: string; - -@IsOptional() @IsNumber() -monthlyRate?: number; -``` - -- [ ] **Step 5: 更新 Occupancy DTO** - -在入住 DTO 中添加: -```typescript -@IsOptional() @IsString() -rentalType?: string; - -@IsOptional() @IsInt() -tenantId?: number; -``` - -- [ ] **Step 6: 更新前端 Rooms 表单** - -在 `Rooms/index.tsx` 的 Modal Form 中添加 `rentalCategory` Select 和 `monthlyRate` InputNumber。 - -- [ ] **Step 7: 更新前端 Occupancies 表单** - -在 `Occupancies/index.tsx` 的 Modal Form 中添加 `rentalType` Select 和 `tenantId` Select。 - -- [ ] **Step 8: Commit** - -```bash -git add apps/server/src/entities/ apps/server/src/rooms/ apps/server/src/occupancies/ apps/admin/src/pages/Rooms/ apps/admin/src/pages/Occupancies/ -git commit -m "feat: add rental_category/rate to Room, rental_type/tenant_id to Occupancy, tenant_id to Student" -``` - ---- - -### Task 3.2: 账单服务长租逻辑 - -**Files:** -- Modify: `apps/server/src/bills/bills.service.ts` - -- [ ] **Step 1: 修改 generate 方法** - -在账单生成逻辑中,遍历 occupancy 时检查 `rentalType`: -```typescript -if (occupancy.rentalType === 'long') { - // 长租:取 room.monthlyRate 作为独立费用,不参与分摊 - const longRentBill = this.billRepo.create({ - studentId: occupancy.studentId, - // ... 其他字段 - totalAmount: room.monthlyRate, - }); - await this.billRepo.save(longRentBill); -} else { - // 短租:走原人天数分摊逻辑 - shortTermOccupancies.push(occupancy); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add apps/server/src/bills/bills.service.ts -git commit -m "feat: add long-term rental independent billing in generate logic" -``` - ---- - -## Phase 4: 操作日志全量接入 - -### Task 4.1: 接入 Students 模块日志 - -**Files:** -- Modify: `apps/server/src/students/students.controller.ts` - -在 create/update/delete/import/export 方法中注入 `OperationLogsService`,每次操作后调用 `log()`。 - -- [ ] **Step 1-3: 添加日志调用 → 验证 → Commit** - ---- - -### Task 4.2: 接入 Occupancies/Expenses/Bills/Deposits 日志 - -**Files:** -- Modify: `apps/server/src/occupancies/occupancies.controller.ts` -- Modify: `apps/server/src/expenses/expenses.controller.ts` -- Modify: `apps/server/src/bills/bills.controller.ts` -- Modify: `apps/server/src/deposits/deposits.controller.ts` - -对未覆盖的写操作注入日志。已有 ClassroomRentals 模块作为完整参考。 - -- [ ] **Step 1-3: 批量添加 → 验证 → Commit** - ---- - -### Task 4.3: 接入 Attendance 日志(Phase 6 依赖) - -**Files:** -- Modify: `apps/server/src/students/students.controller.ts`(考勤接口在学生档案模块中) - -后续 Phase 6 创建独立 attendance 模块时同时在 controller 中接入日志。 - ---- - -## Phase 5: RBAC 权限扩展 - -### Task 5.1: 添加新权限节点 - -**Files:** -- Modify: `apps/server/src/rbac/rbac.service.ts` - -- [ ] **Step 1: 在 PRESET_PERMISSIONS 数组中追加** - -```typescript -{ code: 'class:view', name: '查看班级', group: 'class' }, -{ code: 'class:create', name: '创建班级', group: 'class' }, -{ code: 'class:edit', name: '编辑班级', group: 'class' }, -{ code: 'class:delete', name: '删除班级', group: 'class' }, -{ code: 'schedule:view', name: '查看排课', group: 'schedule' }, -{ code: 'schedule:create', name: '创建排课', group: 'schedule' }, -{ code: 'schedule:edit', name: '编辑排课', group: 'schedule' }, -{ code: 'schedule:delete', name: '删除排课', group: 'schedule' }, -{ code: 'attendance:view', name: '查看考勤', group: 'attendance' }, -{ code: 'attendance:create', name: '新增考勤', group: 'attendance' }, -{ code: 'attendance:edit', name: '编辑考勤', group: 'attendance' }, -``` - -- [ ] **Step 2: 在 PRESET_ROLES 中更新宿管老师角色** - -在 `dormitory_supervisor` 的 `permissionGroups` 中添加:`'class'`, `'schedule'`, `'attendance'` - -- [ ] **Step 3: 验证 — 数据库重新 seed** - -删除 SQLite 数据库后重启后端,确认权限表包含新节点。 - -```bash -rm apps/server/dorm_billing.db && cd apps/server && npm run start:dev -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/rbac/rbac.service.ts -git commit -m "feat: add CLASS/SCHEDULE/ATTENDANCE permission nodes" -``` - ---- - -## Phase 6: 考勤管理前端 - -### Task 6.1: 考勤后端 API 增强 - -**Files:** -- Modify: `apps/server/src/students/students.controller.ts`(或新建 attendance 模块) - -新增 batch/create、summary、calendar、ding-attendance-raw 端点。 - -- [ ] **Step 1-3: 实现 API → Commit** - ---- - -### Task 6.2: 前端考勤页面 - -**Files:** -- Create: `apps/admin/src/pages/Attendance/index.tsx` -- Modify: `apps/admin/src/App.tsx` -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -列表页:筛选(班级/日期/时段/状态/来源)+ 表格 + 批量补录 + 日历视图切换。 - -- [ ] **Step 1-3: 实现 → Commit** - ---- - -## Phase 7: 数据面板增强 - -### Task 7.1: Dashboard API 增强 - -**Files:** -- Modify: `apps/server/src/dashboard/dashboard.service.ts` -- Modify: `apps/server/src/dashboard/dashboard.controller.ts` - -增加 `classroomCount`, `classroomOccupancyRate`, `todayAttendanceRate`, `monthlyIncome`, `attendanceTrend`, `incomeTrend` 字段。 - -- [ ] **Step 1-3: 实现 → Commit** - ---- - -### Task 7.2: 前端 Dashboard 增强 - -**Files:** -- Modify: `apps/admin/src/pages/Dashboard/index.tsx` - -追加第二行指标卡 + 考勤趋势/收入趋势图表。 - -- [ ] **Step 1-3: 实现 → Commit** diff --git a/docs/superpowers/plans/2026-07-05-multi-campus.md b/docs/superpowers/plans/2026-07-05-multi-campus.md deleted file mode 100644 index b6274e2..0000000 --- a/docs/superpowers/plans/2026-07-05-multi-campus.md +++ /dev/null @@ -1,1377 +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:** 新增 `departments`/`user_departments` 表 + `DepartmentsModule` + 请求级 `CampusScope` Provider,各业务实体追加 `department_id` 冗余字段,Controller 通过 `scope.filter()` 自动附加校区过滤,超管绕过。 - -**Tech Stack:** NestJS 11, TypeORM 0.3, React 19, Ant Design 6 - -## Global Constraints - -- 表名使用复数形式:`departments`、`user_departments` -- Entity 使用 `@Entity('table_name')` + `@Column({ name: 'snake_case' })` 模式 -- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 -- Module 必须 `imports: [TypeOrmModule.forFeature([...])]` -- Controller 所有方法 `@UseGuards(JwtAuthGuard)` -- DTO 使用 class-validator 装饰器 -- 前端 axios 实例从 `api/` 导入 -- 前端新增路由在 `App.tsx` 注册 -- 超管 (super_admin 角色) 绕过所有校区隔离 -- `department_id` 采用冗余存储策略,写入时填入,避免查询时多表 JOIN - ---- - -### Task 1: Department + UserDepartment Entity - -**Files:** -- Create: `apps/server/src/entities/department.entity.ts` -- Create: `apps/server/src/entities/user-department.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -**Interfaces:** -- Produces: `Department` entity, `UserDepartment` entity - -- [ ] **Step 1: 创建 department.entity.ts** - -```typescript -// apps/server/src/entities/department.entity.ts -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - OneToMany, - JoinColumn, -} from 'typeorm'; - -export enum DepartmentType { - CAMPUS = 'campus', - DEPARTMENT = 'department', -} - -@Entity('departments') -export class Department { - @PrimaryGeneratedColumn() - id: number; - - @Column({ length: 100 }) - name: string; - - @Column({ name: 'parent_id', type: 'integer', nullable: true }) - parentId: number; - - @ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL' }) - @JoinColumn({ name: 'parent_id' }) - parent: Department; - - @OneToMany(() => Department, (d) => d.parent) - children: Department[]; - - @Column({ length: 20, default: DepartmentType.DEPARTMENT }) - type: string; - - @Column({ name: 'sort_order', type: 'integer', default: 0 }) - sortOrder: number; - - @Column({ length: 20, default: 'active' }) - status: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} -``` - -- [ ] **Step 2: 创建 user-department.entity.ts** - -```typescript -// apps/server/src/entities/user-department.entity.ts -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, - Unique, -} from 'typeorm'; -import { User } from './user.entity'; -import { Department } from './department.entity'; - -@Entity('user_departments') -@Unique(['userId', 'departmentId']) -export class UserDepartment { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'user_id', type: 'integer' }) - userId: number; - - @ManyToOne(() => User, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'user_id' }) - user: User; - - @Column({ name: 'department_id', type: 'integer' }) - departmentId: number; - - @ManyToOne(() => Department, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'department_id' }) - department: Department; - - @Column({ name: 'is_default', type: 'boolean', default: false }) - isDefault: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 3: 在 entities/index.ts 注册导出** - -```typescript -export { Department, DepartmentType } from './department.entity'; -export { UserDepartment } from './user-department.entity'; -``` - -- [ ] **Step 4: 验证 — 启动后端检查建表** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,`departments`、`user_departments` 表自动创建。 - -- [ ] **Step 5: Commit** - -```bash -git add apps/server/src/entities/department.entity.ts apps/server/src/entities/user-department.entity.ts apps/server/src/entities/index.ts -git commit -m "feat: add Department and UserDepartment entities" -``` - ---- - -### Task 2: 现有实体追加 department_id - -**Files:** -- Modify: `apps/server/src/entities/student.entity.ts` -- Modify: `apps/server/src/entities/room.entity.ts` -- Modify: `apps/server/src/entities/classroom.entity.ts` -- Modify: `apps/server/src/entities/class-schedule.entity.ts` -- Modify: `apps/server/src/entities/attendance-record.entity.ts` -- Modify: `apps/server/src/entities/room-expense.entity.ts` -- Modify: `apps/server/src/entities/personal-expense.entity.ts` -- Modify: `apps/server/src/entities/occupancy.entity.ts` -- Modify: `apps/server/src/entities/bill.entity.ts` -- Modify: `apps/server/src/entities/deposit.entity.ts` -- Modify: `apps/server/src/entities/deposit-installment.entity.ts` -- Modify: `apps/server/src/entities/classroom-rental.entity.ts` - -**Interfaces:** -- Consumes: `Department` entity from Task 1 -- Produces: 所有业务实体新增 `departmentId` 字段 + `@ManyToOne` 关系 - -- [ ] **Step 1: 批量追加 department_id** - -对每个实体,追加以下代码块(以 `student.entity.ts` 为例): - -```typescript -// 在 imports 中添加: -import { Department } from './department.entity'; - -// 在类体中添加: -@Column({ name: 'department_id', type: 'integer', nullable: true }) -departmentId: number; - -@ManyToOne(() => Department, { nullable: true }) -@JoinColumn({ name: 'department_id' }) -department: Department; -``` - -追加清单: - -| 文件 | 说明 | -|------|------| -| `student.entity.ts` | 学生归属校区 | -| `room.entity.ts` | 宿舍归属校区 | -| `classroom.entity.ts` | 教室归属校区 | -| `class-schedule.entity.ts` | 排课归属校区(冗余) | -| `attendance-record.entity.ts` | 考勤归属校区(冗余) | -| `room-expense.entity.ts` | 宿舍费用归属校区(冗余) | -| `personal-expense.entity.ts` | 个人费用归属校区(冗余) | -| `occupancy.entity.ts` | 入住归属校区(冗余) | -| `bill.entity.ts` | 账单归属校区(冗余) | -| `deposit.entity.ts` | 押金归属校区(冗余) | -| `deposit-installment.entity.ts` | 押金分期归属校区(冗余) | -| `classroom-rental.entity.ts` | 租赁订单归属校区(冗余) | - -注:`classes` 实体已有 `departmentId`,跳过。 - -- [ ] **Step 2: 验证 — TypeORM 自动 ALTER TABLE** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,所有业务表新增 `department_id` 列。 - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/entities/ -git commit -m "feat: add department_id to all business entities" -``` - ---- - -### Task 3: DepartmentsModule — DTO + Service - -**Files:** -- Create: `apps/server/src/departments/dto/department.dto.ts` -- Create: `apps/server/src/departments/departments.service.ts` -- Create: `apps/server/src/departments/departments.module.ts` - -**Interfaces:** -- Consumes: `Department` + `UserDepartment` entities from Task 1 -- Produces: `DepartmentsService` with `findAll`, `findTree`, `create`, `update`, `remove`, `getDescendantIds`, `getUserDepartments`, `assignUser`, `removeUser` - -- [ ] **Step 1: 创建 DTO** - -```typescript -// apps/server/src/departments/dto/department.dto.ts -import { IsString, IsNotEmpty, IsOptional, IsInt, IsBoolean, IsEnum } from 'class-validator'; - -export class CreateDepartmentDto { - @IsString() @IsNotEmpty() - name: string; - - @IsOptional() @IsInt() - parentId?: number; - - @IsOptional() @IsString() - type?: string; - - @IsOptional() @IsInt() - sortOrder?: number; -} - -export class UpdateDepartmentDto { - @IsOptional() @IsString() - name?: string; - - @IsOptional() @IsInt() - parentId?: number; - - @IsOptional() @IsString() - type?: string; - - @IsOptional() @IsInt() - sortOrder?: number; -} - -export class AssignUserDto { - @IsInt() - userId: number; - - @IsOptional() @IsBoolean() - isDefault?: boolean; -} -``` - -- [ ] **Step 2: 创建 Service** - -```typescript -// apps/server/src/departments/departments.service.ts -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; -import { Department } from '../entities/department.entity'; -import { UserDepartment } from '../entities/user-department.entity'; -import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto'; - -@Injectable() -export class DepartmentsService { - constructor( - @InjectRepository(Department) - private deptRepo: Repository, - @InjectRepository(UserDepartment) - private userDeptRepo: Repository, - ) {} - - async findAll(): Promise { - return this.deptRepo.find({ - where: { status: 'active' }, - order: { sortOrder: 'ASC', name: 'ASC' }, - }); - } - - async findTree(): Promise { - const all = await this.deptRepo.find({ - where: { status: 'active' }, - order: { sortOrder: 'ASC', name: 'ASC' }, - relations: ['children'], - }); - // 返回根节点(parent_id = null) - return all.filter((d) => d.parentId === null); - } - - async findOne(id: number): Promise { - const dept = await this.deptRepo.findOne({ where: { id } }); - if (!dept) throw new NotFoundException('部门不存在'); - return dept; - } - - async create(dto: CreateDepartmentDto): Promise { - const dept = this.deptRepo.create(dto); - return this.deptRepo.save(dept); - } - - async update(id: number, dto: UpdateDepartmentDto): Promise { - const dept = await this.findOne(id); - Object.assign(dept, dto); - return this.deptRepo.save(dept); - } - - async remove(id: number): Promise { - // 检查是否有子部门 - const children = await this.deptRepo.count({ where: { parentId: id } }); - if (children > 0) throw new ConflictException('该部门下存在子部门,无法删除'); - - // 检查是否有关联用户 - const users = await this.userDeptRepo.count({ where: { departmentId: id } }); - if (users > 0) throw new ConflictException('该部门下有用户关联,无法删除'); - - await this.deptRepo.update(id, { status: 'archived' }); - } - - /** 获取部门的所有子部门 ID(递归,含自身) */ - async getDescendantIds(departmentId: number): Promise { - const ids = [departmentId]; - const children = await this.deptRepo.find({ - where: { parentId: departmentId, status: 'active' }, - }); - for (const child of children) { - const childIds = await this.getDescendantIds(child.id); - ids.push(...childIds); - } - return ids; - } - - /** 获取用户可访问的部门 ID 列表 */ - async getUserDepartments(userId: number): Promise { - const records = await this.userDeptRepo.find({ - where: { userId }, - }); - return records.map((r) => r.departmentId); - } - - /** 获取用户默认校区 ID */ - async getUserDefaultDepartmentId(userId: number): Promise { - const record = await this.userDeptRepo.findOne({ - where: { userId, isDefault: true }, - }); - return record?.departmentId ?? null; - } - - /** 获取部门下的用户 */ - async getUsers(departmentId: number): Promise { - return this.userDeptRepo.find({ - where: { departmentId }, - relations: ['user'], - }); - } - - /** 为用户分配部门 */ - async assignUser(departmentId: number, dto: AssignUserDto): Promise { - const record = this.userDeptRepo.create({ - userId: dto.userId, - departmentId, - isDefault: dto.isDefault ?? false, - }); - return this.userDeptRepo.save(record); - } - - /** 移除用户-部门关联 */ - async removeUser(departmentId: number, userId: number): Promise { - await this.userDeptRepo.delete({ departmentId, userId }); - } -} -``` - -- [ ] **Step 3: 创建 Module** - -```typescript -// apps/server/src/departments/departments.module.ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Department } from '../entities/department.entity'; -import { UserDepartment } from '../entities/user-department.entity'; -import { DepartmentsService } from './departments.service'; -import { DepartmentsController } from './departments.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([Department, UserDepartment])], - controllers: [DepartmentsController], - providers: [DepartmentsService], - exports: [DepartmentsService], -}) -export class DepartmentsModule {} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/departments/ -git commit -m "feat: add DepartmentsService with tree query and user assignment" -``` - ---- - -### Task 4: DepartmentsController - -**Files:** -- Create: `apps/server/src/departments/departments.controller.ts` -- Modify: `apps/server/src/app.module.ts` — 注册 DepartmentsModule + 在 TypeORM entities 中添加 Department/UserDepartment - -**Interfaces:** -- Consumes: `DepartmentsService` from Task 3 -- Produces: REST API - -- [ ] **Step 1: 创建 Controller** - -```typescript -// apps/server/src/departments/departments.controller.ts -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; -import { DepartmentsService } from './departments.service'; -import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; - -@UseGuards(JwtAuthGuard) -@Controller('departments') -export class DepartmentsController { - constructor(private readonly service: DepartmentsService) {} - - @Get() - findAll() { - return this.service.findAll(); - } - - @Get('tree') - findTree() { - return this.service.findTree(); - } - - @Get(':id') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); - } - - @Post() - create(@Body() dto: CreateDepartmentDto) { - return this.service.create(dto); - } - - @Put(':id') - update(@Param('id') id: string, @Body() dto: UpdateDepartmentDto) { - return this.service.update(+id, dto); - } - - @Delete(':id') - remove(@Param('id') id: string) { - return this.service.remove(+id); - } - - @Get(':id/users') - getUsers(@Param('id') id: string) { - return this.service.getUsers(+id); - } - - @Post(':id/users') - assignUser(@Param('id') id: string, @Body() dto: AssignUserDto) { - return this.service.assignUser(+id, dto); - } - - @Delete(':id/users/:userId') - removeUser(@Param('id') id: string, @Param('userId') userId: string) { - return this.service.removeUser(+id, +userId); - } -} -``` - -- [ ] **Step 2: 注册到 AppModule** - -在 `apps/server/src/app.module.ts` 中: - -1. 在 `TypeOrmModule.forRootAsync` 的 `allEntities` 数组中添加: -```typescript -Department, -UserDepartment, -``` - -2. 在 `@Module imports` 中添加: -```typescript -DepartmentsModule, -``` - -- [ ] **Step 3: 验证 — 测试 API** - -```bash -# 创建校区 -curl -X POST http://localhost:3000/api/departments \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"name":"鼓楼校区","type":"campus"}' - -# 获取树 -curl http://localhost:3000/api/departments/tree \ - -H "Authorization: Bearer " -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/departments/departments.controller.ts apps/server/src/app.module.ts -git commit -m "feat: add DepartmentsController with CRUD + user assignment" -``` - ---- - -### Task 5: CampusScope + 请求级注入 - -**Files:** -- Create: `apps/server/src/common/campus-scope.ts` -- Modify: `apps/server/src/app.module.ts` — 注册 CampusScope provider - -**Interfaces:** -- Produces: `CampusScope` 请求级 Provider,通过 `scope.filter()` 在查询条件中自动追加 `departmentId` - -- [ ] **Step 1: 创建 CampusScope** - -```typescript -// apps/server/src/common/campus-scope.ts -import { Injectable, Scope, Inject } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; -import { In } from 'typeorm'; -import { DepartmentsService } from '../departments/departments.service'; - -@Injectable({ scope: Scope.REQUEST }) -export class CampusScope { - constructor( - @Inject(REQUEST) private req: any, - private departmentsService: DepartmentsService, - ) {} - - get userId(): number { - return this.req.user?.id; - } - - get isSuperAdmin(): boolean { - return this.req.user?.isSuperAdmin ?? false; - } - - get currentDepartmentId(): number | null { - const headerId = parseInt(this.req.headers?.['x-campus-id'] || '0', 10); - return headerId || null; - } - - /** 对 TypeORM find where 条件追加校区过滤 */ - async filter>(where: T): Promise { - // 超管 + 未选校区 → 不过滤 - if (this.isSuperAdmin && !this.currentDepartmentId) { - return where; - } - - const ids = await this.getEffectiveScopeIds(); - if (ids.length === 0) return where; - - return { ...where, departmentId: In(ids) } as any; - } - - private async getEffectiveScopeIds(): Promise { - // 如果用户选了具体校区 → 该校区 + 子部门 - if (this.currentDepartmentId) { - return this.departmentsService.getDescendantIds(this.currentDepartmentId); - } - - // 未选校区 → 用户关联的所有部门 + 子部门 - const userDeptIds = await this.departmentsService.getUserDepartments(this.userId); - const allIds = await Promise.all( - userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)), - ); - return [...new Set(allIds.flat())]; - } -} -``` - -- [ ] **Step 2: 创建 CampusScopeMiddleware 将 scope 注入 req** - -```typescript -// apps/server/src/common/campus-scope.middleware.ts -import { Injectable, NestMiddleware } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; -import { CampusScope } from './campus-scope'; - -@Injectable() -export class CampusScopeMiddleware implements NestMiddleware { - constructor(private readonly scope: CampusScope) {} - - use(req: Request, _res: Response, next: NextFunction) { - (req as any).campusScope = this.scope; - next(); - } -} -``` - -- [ ] **Step 3: 在 AppModule 中注册** - -在 `apps/server/src/app.module.ts` 中: - -```typescript -import { CampusScope } from './common/campus-scope'; -import { CampusScopeMiddleware } from './common/campus-scope.middleware'; -import { MiddlewareConsumer, NestModule } from '@nestjs/common'; - -// 在 providers 中添加: -CampusScope, - -// AppModule 实现 NestModule: -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer.apply(CampusScopeMiddleware).forRoutes('*'); - } -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/common/campus-scope.ts apps/server/src/common/campus-scope.middleware.ts apps/server/src/app.module.ts -git commit -m "feat: add CampusScope request-level provider for data isolation" -``` - ---- - -### Task 6: 各 Service 接入 CampusScope.filter() - -**Files:** -- Modify: `apps/server/src/students/students.service.ts` -- Modify: `apps/server/src/rooms/rooms.service.ts` -- Modify: `apps/server/src/occupancies/occupancies.service.ts` -- Modify: `apps/server/src/bills/bills.service.ts` -- Modify: `apps/server/src/expenses/expenses.service.ts` -- Modify: `apps/server/src/classes/classes.service.ts` -- Modify: `apps/server/src/schedules/schedules.service.ts` -- Modify: `apps/server/src/attendance/attendance.service.ts` -- Modify: `apps/server/src/deposits/deposits.service.ts` -- Modify: `apps/server/src/classrooms/classrooms.service.ts` -- Modify: `apps/server/src/classroom-rentals/classroom-rentals.service.ts` -- Modify: `apps/server/src/dashboard/dashboard.service.ts` - -**Interfaces:** -- Consumes: `CampusScope` from Task 5 -- Modifies: 所有查询方法通过 `scope.filter()` 追加校区过滤 - -- [ ] **Step 1: 模式说明** - -每个 service 的查询方法改造模式(以 `StudentsService.findAll` 为例): - -```typescript -// 修改前: -async findAll(query: any) { - return this.repo.find({ where: { status: 'active' } }); -} - -// 修改后: -async findAll(query: any) { - const where = await this.scope.filter({ status: 'active' }); - return this.repo.find({ where }); -} -``` - -- [ ] **Step 2: 各 Service 注入 CampusScope** - -对于每个 Controller/Service 文件,需要: -1. `import { CampusScope } from '../common/campus-scope';` -2. constructor 注入: `private scope: CampusScope` -3. 所有 `find`/`findAndCount`/`createQueryBuilder` 查询用 `await this.scope.filter(where)` 包裹 - -但不是所有 module 都注入了 `DepartmentsModule`(CampusScope 依赖它)。需要在各个需要 CampusScope 的 module 的 imports 中添加 `DepartmentsModule`。 - -```typescript -// 在每个受影响的 module 中添加: -import { DepartmentsModule } from '../departments/departments.module'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([...]), - DepartmentsModule, // 新增 - ], -}) -``` - -- [ ] **Step 3: 改造清单** - -逐个 service 改造 `findAll` 类方法(有查询条件的方法): - -| Service | 方法 | 操作 | -|---------|------|------| -| `students.service.ts` | `findAll` | `await this.scope.filter(where)` | -| `rooms.service.ts` | `findAll`, `findVisual` | 同上 | -| `occupancies.service.ts` | `findAll` | 同上 | -| `bills.service.ts` | `findAll` | 同上 | -| `expenses.service.ts` | 所有查询 | 同上 | -| `classes.service.ts` | `findAll` | 同上 | -| `schedules.service.ts` | `findAll`, `findWeekly` | 同上 | -| `attendance.service.ts` | 所有查询 | 同上 | -| `deposits.service.ts` | 所有查询 | 同上 | -| `classrooms.service.ts` | `findAll` | 同上 | -| `classroom-rentals.service.ts` | `findAll` | 同上 | -| `dashboard.service.ts` | `getStats` | 同上 | - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/ -git commit -m "feat: integrate CampusScope.filter() into all business services" -``` - ---- - -### Task 7: 写操作时填充 department_id - -**Files:** -- Modify: 各 Service 的 create/update 方法 - -**Interfaces:** -- Modifies: 创建记录时自动填充 `departmentId`(从关联实体或前端输入获取) - -- [ ] **Step 1: 创建 Room 时填充 department_id** - -```typescript -// rooms.service.ts create() -async create(dto: CreateRoomDto) { - const room = this.repo.create({ - ...dto, - departmentId: dto.departmentId, // 前端传入(由校区选择器当前选中值决定) - }); - return this.repo.save(room); -} -``` - -`rooms.dto.ts` 需新增字段: -```typescript -@IsOptional() @IsInt() -departmentId?: number; -``` - -- [ ] **Step 2: 创建 Student 时从关联 Class 获取 department_id** - -```typescript -// students.service.ts create() -async create(dto: CreateStudentDto) { - let departmentId = dto.departmentId; - if (!departmentId && dto.classId) { - const cls = await this.classesRepo.findOne({ where: { id: dto.classId } }); - departmentId = cls?.departmentId || null; - } - const student = this.repo.create({ ...dto, departmentId }); - return this.repo.save(student); -} -``` - -- [ ] **Step 3: 其他实体类似处理** - -| 实体创建时 | department_id 来源 | -|-----------|-------------------| -| Occupancy | 从关联的 Room.departmentId | -| Bill | 从关联的 Student.departmentId | -| PersonalExpense | 从关联的 Student.departmentId | -| RoomExpense | 从关联的 Room.departmentId | -| AttendanceRecord | 从关联的 Student.departmentId | -| Deposit | 从关联的 Student.departmentId | -| DepositInstallment | 从关联的 Deposit.departmentId | -| ClassSchedule | 从关联的 Class.departmentId 或前端传入 | -| ClassroomRental | 从关联的 Classroom.departmentId | -| Classroom | 前端传入 | - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/ -git commit -m "feat: auto-populate department_id on entity creation" -``` - ---- - -### Task 8: JWT Payload 扩展 + 登录注入 isSuperAdmin - -**Files:** -- Modify: `apps/server/src/auth/auth.service.ts` -- Modify: `apps/server/src/auth/strategies/jwt.strategy.ts` - -**Interfaces:** -- Modifies: JWT payload 增加 `isSuperAdmin` 标记 - -- [ ] **Step 1: auth.service.ts — login() 注入 isSuperAdmin** - -在 `login()` 方法的 payload 构造处: - -```typescript -// auth.service.ts -const isSuperAdmin = user.roles?.some(r => r.name === 'super_admin') ?? false; -const payload = { - sub: user.id, - username: user.username, - permissions, - isSuperAdmin, -}; -``` - -- [ ] **Step 2: jwt.strategy.ts — validate() 透传 isSuperAdmin** - -```typescript -// jwt.strategy.ts validate() -async validate(payload: any) { - return { - id: payload.sub, - username: payload.username, - permissions: payload.permissions || [], - isSuperAdmin: payload.isSuperAdmin || false, - }; -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/auth/ -git commit -m "feat: add isSuperAdmin to JWT payload for campus scope bypass" -``` - ---- - -### Task 9: 数据回填 — 默认校区 + 历史数据迁移 - -**Files:** -- Create: `apps/server/src/departments/seed.service.ts`(或直接用 migration script) - -- [ ] **Step 1: 创建 seed 脚本** - -```typescript -// apps/server/src/departments/seed.ts -// 在 NestJS bootstrap 后执行(或作为独立脚本运行) -import { DataSource } from 'typeorm'; - -export async function seedDefaultCampus(dataSource: DataSource) { - const deptRepo = dataSource.getRepository('departments'); - const userDeptRepo = dataSource.getRepository('user_departments'); - - // 1. 检查是否已有校区数据 - const existing = await deptRepo.count(); - if (existing > 0) { - console.log('Departments already exist, skipping seed'); - return; - } - - // 2. 创建默认校区 - const campus = await deptRepo.save({ - name: '主校区', - type: 'campus', - sortOrder: 0, - }); - - // 3. 回填所有业务数据的 department_id - const tables = [ - 'students', 'rooms', 'classrooms', 'class_schedules', - 'attendance_records', 'room_expenses', 'personal_expenses', - 'occupancies', 'bills', 'deposits', 'deposit_installments', - 'classroom_rentals', - ]; - - for (const table of tables) { - await dataSource.query( - `UPDATE ${table} SET department_id = ? WHERE department_id IS NULL`, - [campus.id], - ); - } - - // 4. 所有现有用户关联到默认校区 - const users = await dataSource.query('SELECT id FROM users'); - for (const user of users) { - await userDeptRepo.save({ - userId: user.id, - departmentId: campus.id, - isDefault: true, - }); - } - - console.log('Seed complete: default campus created, data backfilled'); -} -``` - -- [ ] **Step 2: 在 main.ts 中注册 seed** - -```typescript -// apps/server/src/main.ts -// 在 app.listen() 之前: -const dataSource = app.get(DataSource); -await seedDefaultCampus(dataSource); -``` - -- [ ] **Step 3: 运行验证** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动日志显示 "Seed complete: default campus created, data backfilled"。 - -验证:查询 `SELECT COUNT(*) FROM students WHERE department_id IS NULL` 应为 0。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/departments/seed.ts apps/server/src/main.ts -git commit -m "feat: add default campus seed with historical data backfill" -``` - ---- - -### Task 10: 前端 — useCampus Hook + CampusSwitcher 组件 - -**Files:** -- Create: `apps/admin/src/hooks/useCampus.ts` -- Create: `apps/admin/src/components/CampusSwitcher.tsx` - -**Interfaces:** -- Consumes: `GET /api/departments/tree` + `GET /api/departments` (for flat list) -- Produces: `useCampus` hook, `CampusSwitcher` component - -- [ ] **Step 1: 创建 useCampus hook** - -```typescript -// apps/admin/src/hooks/useCampus.ts -import { useState, useEffect, useCallback } from 'react'; -import api from '../api'; - -interface Department { - id: number; - name: string; - type: string; - parentId: number | null; -} - -export function useCampus() { - const [campuses, setCampuses] = useState([]); - const [currentId, setCurrentId] = useState( - () => localStorage.getItem('currentCampusId') || '' - ); - const [loading, setLoading] = useState(true); - - const fetchCampuses = useCallback(async () => { - try { - // 只取校区级部门(type=campus) - const data = await api.get('/departments') as unknown as Department[]; - const campusList = data.filter((d) => d.type === 'campus'); - setCampuses(campusList); - - // 如果没有选中校区,选第一个 - if (!currentId && campusList.length > 0) { - setCurrentId(String(campusList[0].id)); - localStorage.setItem('currentCampusId', String(campusList[0].id)); - } - } catch { - // 静默失败 - } finally { - setLoading(false); - } - }, [currentId]); - - useEffect(() => { - fetchCampuses(); - }, []); - - const switchCampus = useCallback((id: string) => { - setCurrentId(id); - localStorage.setItem('currentCampusId', id); - // 触发全局数据刷新 - window.dispatchEvent(new CustomEvent('campus-changed', { detail: id })); - }, []); - - return { campuses, currentId, switchCampus, loading }; -} -``` - -- [ ] **Step 2: 创建 CampusSwitcher 组件** - -```tsx -// apps/admin/src/components/CampusSwitcher.tsx -import React from 'react'; -import { Select, Typography } from 'antd'; -import { EnvironmentOutlined } from '@ant-design/icons'; -import { useCampus } from '../hooks/useCampus'; - -const CampusSwitcher: React.FC = () => { - const { campuses, currentId, switchCampus, loading } = useCampus(); - - // 只有一个校区 → 纯文本展示 - if (campuses.length <= 1) { - return ( - - - {campuses[0]?.name || '主校区'} - - ); - } - - const options = [ - ...campuses.map((c) => ({ value: String(c.id), label: c.name })), - { value: '', label: '全部校区' }, - ]; - - return ( -
v ? '是' : '否' }, - ]} - size="small" - /> - - ) : ( - -
- 请从左侧选择一个部门 -
-
- )} - - - setModalOpen(false)} - > -
- - - - - - - - - - -
- - ); -}; - -export default DepartmentsPage; -``` - -- [ ] **Step 2: 在 App.tsx 注册路由** - -```tsx -import DepartmentsPage from './pages/Departments'; - -// 在 Routes 内添加: - - - -}> - } /> - -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Departments/index.tsx apps/admin/src/App.tsx -git commit -m "feat: add Departments management page with tree + user list" -``` - ---- - -### Task 13: 验证 + 端到端测试 - -- [ ] **Step 1: 启动完整环境** - -```bash -cd apps/server && npm run start:dev & -cd apps/admin && npm run dev & -``` - -- [ ] **Step 2: 测试流程** - -1. 打开 `http://localhost:5173`,用超管登录 -2. 访问 `/departments` → 确认默认「主校区」存在 -3. 创建第二个校区「江宁校区」 -4. 进入「账号管理」→ 编辑某个教职工,将其分配到「江宁校区」 -5. 用该教职工登录 → Header 显示校区选择器,可切换 -6. 切换到「江宁校区」→ 学生列表/宿舍列表只显示江宁数据 -7. 切换到「全部校区」→ 显示两个校区数据 -8. 超管不选校区 → 显示全部数据(无隔离) - -- [ ] **Step 3: 数据隔离验证** - -SQL 验证: -```sql --- 确认历史数据已回填 -SELECT COUNT(*) FROM students WHERE department_id IS NULL; -- 期望 0 -SELECT COUNT(*) FROM rooms WHERE department_id IS NULL; -- 期望 0 - --- 确认新创建实体自动填充 -INSERT INTO students (...) VALUES (...); --- 应自动填 department_id -``` - -- [ ] **Step 4: Commit (如有调整)** - -```bash -git add -A -git commit -m "fix: campus isolation tweaks and seed adjustments" -``` diff --git a/docs/superpowers/plans/2026-07-05-notification-center.md b/docs/superpowers/plans/2026-07-05-notification-center.md deleted file mode 100644 index d0647fc..0000000 --- a/docs/superpowers/plans/2026-07-05-notification-center.md +++ /dev/null @@ -1,1273 +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:** 为系统全部角色提供站内信通知中心,支持 SSE 实时推送 + 轮询兜底,预留钉钉/企微外发扩展点。 - -**Architecture:** 新增 `notifications` 表 + `NotificationsModule`,NestJS `@Sse()` 实现 SSE 推送,前端 `EventSource` + `useNotifications` hook,Header 铃铛 Badge + Popover + 全屏通知页。 - -**Tech Stack:** NestJS 11, TypeORM 0.3, RxJS, React 19, Ant Design 6, EventSource API - -## Global Constraints - -- 表名使用复数形式 `notifications` -- Entity 使用 `@Entity('notifications')` + `@Column({ name: 'snake_case' })` 模式 -- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 -- Module 必须 `imports: [TypeOrmModule.forFeature([Notification])]` -- Controller 所有方法 `@UseGuards(JwtAuthGuard)` -- DTO 使用 class-validator 装饰器 -- 前端 axios 实例从 `api/` 导入 -- 前端新增路由在 `App.tsx` 注册 -- SSE 端点需支持从 query string 提取 JWT token(EventSource 不支持自定义 header) - ---- - -### Task 1: Notification Entity - -**Files:** -- Create: `apps/server/src/entities/notification.entity.ts` -- Modify: `apps/server/src/entities/index.ts` - -**Interfaces:** -- Produces: `Notification` entity class — exports for TypeORM `@Entity('notifications')` - -- [ ] **Step 1: 创建 notification.entity.ts** - -```typescript -// apps/server/src/entities/notification.entity.ts -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { User } from './user.entity'; - -export enum NotificationType { - BILL_GENERATED = 'bill_generated', - BILL_PAID = 'bill_paid', - CHECK_IN = 'check_in', - CHECK_OUT = 'check_out', - DEPOSIT_DUE = 'deposit_due', - DEPOSIT_REFUNDED = 'deposit_refunded', - CLASS_CHANGE = 'class_change', - SCHEDULE_CONFLICT = 'schedule_conflict', - ANNOUNCEMENT = 'announcement', -} - -@Entity('notifications') -export class Notification { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'recipient_id', type: 'integer' }) - recipientId: number; - - @ManyToOne(() => User, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'recipient_id' }) - recipient: User; - - @Column({ name: 'type', length: 30 }) - type: string; - - @Column({ name: 'title', length: 200 }) - title: string; - - @Column({ name: 'content', type: 'text', nullable: true }) - content: string; - - @Column({ name: 'link', length: 500, nullable: true }) - link: string; - - @Column({ name: 'is_read', type: 'boolean', default: false }) - isRead: boolean; - - @Column({ name: 'read_at', type: 'datetime', nullable: true }) - readAt: Date; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} -``` - -- [ ] **Step 2: 在 entities/index.ts 中注册导出** - -在 `apps/server/src/entities/index.ts` 末尾添加: -```typescript -export { Notification, NotificationType } from './notification.entity'; -``` - -- [ ] **Step 3: 验证 — 启动后端检查 TypeORM 自动建表** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功,`notifications` 表自动创建。 - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/entities/notification.entity.ts apps/server/src/entities/index.ts -git commit -m "feat: add Notification entity" -``` - ---- - -### Task 2: Notifications Module — DTO + Service - -**Files:** -- Create: `apps/server/src/notifications/dto/notification.dto.ts` -- Create: `apps/server/src/notifications/notifications.service.ts` -- Create: `apps/server/src/notifications/notifications.module.ts` - -**Interfaces:** -- Consumes: `Notification` entity from Task 1 -- Produces: `NotificationsService` with methods: `create`, `findByUser`, `getUnreadCount`, `markRead`, `markAllRead`, `subscribe` - -- [ ] **Step 1: 创建 DTO** - -```typescript -// apps/server/src/notifications/dto/notification.dto.ts -import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt, IsEnum } from 'class-validator'; -import { NotificationType } from '../../entities/notification.entity'; - -export class CreateNotificationDto { - @IsArray() - @IsInt({ each: true }) - recipientIds: number[]; - - @IsString() - @IsNotEmpty() - type: string; - - @IsString() - @IsNotEmpty() - title: string; - - @IsOptional() - @IsString() - content?: string; - - @IsOptional() - @IsString() - link?: string; -} - -export class NotificationQueryDto { - @IsOptional() - @IsInt() - after?: number; - - @IsOptional() - @IsInt() - limit?: number; -} -``` - -- [ ] **Step 2: 创建 Service** - -```typescript -// apps/server/src/notifications/notifications.service.ts -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan } from 'typeorm'; -import { Subject, Observable } from 'rxjs'; -import { filter } from 'rxjs/operators'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Notification } from '../entities/notification.entity'; -import { CreateNotificationDto } from './dto/notification.dto'; - -@Injectable() -export class NotificationsService { - private subjects = new Map>(); - - constructor( - @InjectRepository(Notification) - private repo: Repository, - private eventEmitter: EventEmitter2, - ) {} - - async create(dto: CreateNotificationDto): Promise { - const notifications = dto.recipientIds.map((recipientId) => - this.repo.create({ - recipientId, - type: dto.type, - title: dto.title, - content: dto.content ?? '', - link: dto.link ?? null, - }), - ); - const saved = await this.repo.save(notifications); - - // 推送 SSE + emit 事件 - for (const n of saved) { - this.subjects.get(n.recipientId)?.next(n); - this.eventEmitter.emit('notification.created', n); - } - - return saved; - } - - async findByUser( - userId: number, - after?: number, - limit: number = 20, - ): Promise { - const qb = this.repo - .createQueryBuilder('n') - .where('n.recipientId = :userId', { userId }) - .orderBy('n.createdAt', 'DESC') - .take(limit); - - if (after) { - qb.andWhere('n.id < :after', { after }); - } - - return qb.getMany(); - } - - async getUnreadCount(userId: number): Promise { - return this.repo.count({ - where: { recipientId: userId, isRead: false }, - }); - } - - async markRead(id: number, userId: number): Promise { - await this.repo.update( - { id, recipientId: userId }, - { isRead: true, readAt: new Date() }, - ); - } - - async markAllRead(userId: number): Promise { - await this.repo.update( - { recipientId: userId, isRead: false }, - { isRead: true, readAt: new Date() }, - ); - } - - subscribe(userId: number): Observable { - if (!this.subjects.has(userId)) { - this.subjects.set(userId, new Subject()); - } - return this.subjects.get(userId)!.asObservable(); - } - - unsubscribe(userId: number): void { - const subj = this.subjects.get(userId); - if (subj) { - subj.complete(); - this.subjects.delete(userId); - } - } -} -``` - -- [ ] **Step 3: 创建 Module** - -```typescript -// apps/server/src/notifications/notifications.module.ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Notification } from '../entities/notification.entity'; -import { NotificationsService } from './notifications.service'; -import { NotificationsController } from './notifications.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([Notification])], - controllers: [NotificationsController], - providers: [NotificationsService], - exports: [NotificationsService], -}) -export class NotificationsModule {} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/notifications/ -git commit -m "feat: add NotificationsService with SSE subject pool" -``` - ---- - -### Task 3: Notifications Controller + SSE Endpoint - -**Files:** -- Create: `apps/server/src/notifications/notifications.controller.ts` -- Modify: `apps/server/src/app.module.ts` — 注册 NotificationsModule - -**Interfaces:** -- Consumes: `NotificationsService` from Task 2 -- Produces: REST API + SSE stream endpoint - -- [ ] **Step 1: 创建 Controller** - -```typescript -// apps/server/src/notifications/notifications.controller.ts -import { - Controller, - Get, - Put, - Param, - Query, - Req, - Sse, - UseGuards, -} from '@nestjs/common'; -import { Request } from 'express'; -import { Observable, map } from 'rxjs'; -import { NotificationsService } from './notifications.service'; -import { NotificationQueryDto } from './dto/notification.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; - -@UseGuards(JwtAuthGuard) -@Controller('notifications') -export class NotificationsController { - constructor(private readonly service: NotificationsService) {} - - @Get() - async findAll(@Req() req: any, @Query() query: NotificationQueryDto) { - const userId = req.user.id; - return this.service.findByUser(userId, query.after, query.limit ?? 20); - } - - @Get('unread-count') - async unreadCount(@Req() req: any) { - const userId = req.user.id; - const count = await this.service.getUnreadCount(userId); - return { count }; - } - - @Sse('stream') - stream(@Req() req: any): Observable { - const userId = req.user.id; - return this.service.subscribe(userId).pipe( - map((notification) => ({ - data: JSON.stringify({ - id: notification.id, - type: notification.type, - title: notification.title, - content: notification.content, - link: notification.link, - createdAt: notification.createdAt, - }), - } as MessageEvent)), - ); - } - - @Put(':id/read') - async markRead(@Param('id') id: string, @Req() req: any) { - await this.service.markRead(+id, req.user.id); - return { success: true }; - } - - @Put('read-all') - async markAllRead(@Req() req: any) { - await this.service.markAllRead(req.user.id); - return { success: true }; - } -} -``` - -- [ ] **Step 2: 在 AppModule 中注册 NotificationsModule** - -在 `apps/server/src/app.module.ts` 中: -1. 在 imports 数组中添加 `NotificationsModule,` -2. 在 entities 数组中添加 `Notification,` - -```typescript -// 在 TypeOrmModule.forRootAsync 的 allEntities 数组中添加: -Notification, - -// 在 @Module imports 中添加: -NotificationsModule, -``` - -- [ ] **Step 3: 验证 — 启动后端测试 API** - -```bash -cd apps/server && npm run start:dev -``` - -Expected: 启动成功。用 curl 测试(先登录获取 token): -```bash -# 获取未读数 -curl -H "Authorization: Bearer " http://localhost:3000/api/notifications/unread-count -# Expected: {"count":0} -``` - -- [ ] **Step 4: Commit** - -```bash -git add apps/server/src/notifications/notifications.controller.ts apps/server/src/app.module.ts -git commit -m "feat: add NotificationsController with SSE stream endpoint" -``` - ---- - -### Task 4: JWT SSE 认证适配 - -**Files:** -- Modify: `apps/server/src/auth/guards/jwt-auth.guard.ts` - -**Interfaces:** -- Modifies: `JwtAuthGuard` — SSE 请求从 query string 提取 token 作为 fallback - -- [ ] **Step 1: 修改 JwtAuthGuard 支持 query token** - -```typescript -// apps/server/src/auth/guards/jwt-auth.guard.ts -// 在已有的 JwtAuthGuard 类中,重写 getRequest 或在 canActivate 前增加 extractor - -// 方案:创建自定义 guard 扩展 -``` - -实际上 `passport-jwt` 的 `ExtractJwt.fromAuthHeaderAsBearerToken()` 不支持 query。需要在 strategy 层面处理。 - -修改 `apps/server/src/auth/strategies/jwt.strategy.ts`: - -```typescript -// apps/server/src/auth/strategies/jwt.strategy.ts -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { - super({ - jwtFromRequest: ExtractJwt.fromExtractors([ - // 1. 标准 Bearer header - ExtractJwt.fromAuthHeaderAsBearerToken(), - // 2. SSE 场景:query string ?token= - (req: Request) => { - const token = req?.query?.token; - if (typeof token === 'string' && token.length > 0) { - return token; - } - return null; - }, - ]), -``` - -保留 `ignoreExpiration` 和 `secretOrKey` 不变。 - -- [ ] **Step 2: 验证 — SSE 端点认证** - -```bash -cd apps/server && npm run start:dev -# 用浏览器或 curl 测试 SSE: -curl -N "http://localhost:3000/api/notifications/stream?token=" -# Expected: 连接保持,无错误 -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/server/src/auth/strategies/jwt.strategy.ts -git commit -m "feat: support JWT from query string for SSE endpoints" -``` - ---- - -### Task 5: 事件监听 + 钉钉/企微外发(预留) - -**Files:** -- Modify: `apps/server/src/notifications/notifications.module.ts` — 注册 EventEmitter - -**Interfaces:** -- Produces: `notification.created` 事件发射,供钉钉/企微模块异步监听 - -- [ ] **Step 1: 安装依赖** - -```bash -cd apps/server && npm install @nestjs/event-emitter -``` - -- [ ] **Step 2: 注册 EventEmitterModule** - -确保 `apps/server/src/app.module.ts` 中已引入 `EventEmitterModule.forRoot()`。检查是否已存在: - -```bash -grep -r "EventEmitter" apps/server/src/app.module.ts -``` - -如果不存在,添加: -```typescript -import { EventEmitterModule } from '@nestjs/event-emitter'; - -// 在 @Module imports 中添加: -EventEmitterModule.forRoot(), -``` - -- [ ] **Step 3: 验证 — 事件发射不报错** - -Service 中的 `this.eventEmitter.emit('notification.created', n)` 应在 EventEmitter 注册后正常工作。启动后端确认无报错。 - -- [ ] **Step 4: Commit** -``` - ---- - -### Task 6: 前端 — useNotifications Hook + NotificationBell 组件 - -**Files:** -- Create: `apps/admin/src/hooks/useNotifications.ts` -- Create: `apps/admin/src/components/NotificationBell.tsx` - -**Interfaces:** -- Consumes: `/api/notifications/unread-count` and `/api/notifications/stream` -- Produces: `useNotifications` hook, `NotificationBell` component - -- [ ] **Step 1: 创建 useNotifications hook** - -```typescript -// apps/admin/src/hooks/useNotifications.ts -import { useState, useEffect, useCallback } from 'react'; -import api from '../api'; - -interface Notification { - id: number; - type: string; - title: string; - content: string; - link: string | null; - createdAt: string; -} - -export function useNotifications() { - const [unreadCount, setUnreadCount] = useState(0); - const [latestNotification, setLatestNotification] = useState(null); - - const fetchUnreadCount = useCallback(async () => { - try { - const data = await api.get('/notifications/unread-count') as unknown as { count: number }; - setUnreadCount(data.count); - } catch { - // 静默失败 - } - }, []); - - useEffect(() => { - // 初始加载 - fetchUnreadCount(); - - const token = localStorage.getItem('token'); - if (!token) return; - - // SSE 连接 - const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); - - es.onmessage = (event) => { - try { - const notification = JSON.parse(event.data) as Notification; - setUnreadCount((c) => c + 1); - setLatestNotification(notification); - } catch { - // 解析失败忽略 - } - }; - - es.onerror = () => { - // SSE 断开 → 切换到轮询兜底 - es.close(); - const interval = setInterval(() => { - fetchUnreadCount(); - }, 60_000); - return () => clearInterval(interval); - }; - - return () => { - es.close(); - }; - }, [fetchUnreadCount]); - - const markAsRead = useCallback(async (id: number) => { - try { - await api.put(`/notifications/${id}/read`); - setUnreadCount((c) => Math.max(0, c - 1)); - } catch { - // 静默失败 - } - }, []); - - const markAllAsRead = useCallback(async () => { - try { - await api.put('/notifications/read-all'); - setUnreadCount(0); - } catch { - // 静默失败 - } - }, []); - - return { unreadCount, latestNotification, markAsRead, markAllAsRead }; -} -``` - -- [ ] **Step 2: 创建 NotificationBell 组件** - -```tsx -// apps/admin/src/components/NotificationBell.tsx -import React, { useState, useEffect } from 'react'; -import { Badge, Popover, Button, List, Typography, Empty, Space } from 'antd'; -import { BellOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import api from '../api'; - -interface NotificationItem { - id: number; - type: string; - title: string; - content: string; - link: string | null; - isRead: boolean; - createdAt: string; -} - -const typeLabels: Record = { - bill_generated: '账单', - bill_paid: '账单', - check_in: '入住', - check_out: '退宿', - deposit_due: '押金', - deposit_refunded: '押金', - class_change: '班级', - schedule_conflict: '排课', - announcement: '公告', -}; - -function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return '刚刚'; - if (mins < 60) return `${mins}分钟前`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}小时前`; - const days = Math.floor(hours / 24); - return `${days}天前`; -} - -const NotificationBell: React.FC = () => { - const [unreadCount, setUnreadCount] = useState(0); - const [notifications, setNotifications] = useState([]); - const [open, setOpen] = useState(false); - const navigate = useNavigate(); - - const fetchNotifications = async () => { - try { - const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[]; - setNotifications(data); - } catch { /* ignore */ } - }; - - const fetchUnread = async () => { - try { - const data = await api.get('/notifications/unread-count') as unknown as { count: number }; - setUnreadCount(data.count); - } catch { /* ignore */ } - }; - - useEffect(() => { - fetchUnread(); - // SSE - const token = localStorage.getItem('token'); - if (!token) return; - const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); - es.onmessage = (event) => { - try { - JSON.parse(event.data); - setUnreadCount((c) => c + 1); - if (open) fetchNotifications(); - } catch { /* ignore */ } - }; - es.onerror = () => { - es.close(); - const interval = setInterval(fetchUnread, 60_000); - return () => clearInterval(interval); - }; - return () => es.close(); - }, [open]); - - const handleOpen = (visible: boolean) => { - setOpen(visible); - if (visible) fetchNotifications(); - }; - - const handleClick = async (item: NotificationItem) => { - if (!item.isRead) { - try { - await api.put(`/notifications/${item.id}/read`); - setUnreadCount((c) => Math.max(0, c - 1)); - } catch { /* ignore */ } - } - setOpen(false); - if (item.link) navigate(item.link); - }; - - const handleMarkAll = async () => { - try { - await api.put('/notifications/read-all'); - setUnreadCount(0); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); - } catch { /* ignore */ } - }; - - const content = ( -
-
- 通知中心 - -
- {notifications.length === 0 ? ( -
- -
- ) : ( - ( - handleClick(item)} - style={{ - padding: '12px 16px', - cursor: 'pointer', - backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', - }} - > - - ) - } - title={ - - [{typeLabels[item.type] || item.type}] {item.title} - - } - description={ - - {timeAgo(item.createdAt)} - - } - /> - - )} - /> - )} -
- -
-
- ); - - return ( - - - - - - ); -}; - -export default NotificationBell; -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/admin/src/hooks/useNotifications.ts apps/admin/src/components/NotificationBell.tsx -git commit -m "feat: add useNotifications hook and NotificationBell component" -``` - ---- - -### Task 7: 前端 — MainLayout 集成铃铛 - -**Files:** -- Modify: `apps/admin/src/layouts/MainLayout.tsx` - -- [ ] **Step 1: 在 Header 中添加 NotificationBell** - -在 `MainLayout.tsx` 的 Header 右侧区域(用户头像/下拉菜单旁边)添加 `NotificationBell`: - -```tsx -// apps/admin/src/layouts/MainLayout.tsx -// 在 imports 中添加: -import NotificationBell from '../components/NotificationBell'; - -// 在 Header 右侧区域(通常在用户 Dropdown 之前): - -``` - -具体定位:找到 Header 中 `Dropdown` / `Avatar` 相关的 JSX,在其前面插入 ``。 - -- [ ] **Step 2: Commit** - -```bash -git add apps/admin/src/layouts/MainLayout.tsx -git commit -m "feat: integrate NotificationBell into MainLayout header" -``` - ---- - -### Task 8: 前端 — 全屏通知页 - -**Files:** -- Create: `apps/admin/src/pages/Notifications/index.tsx` -- Modify: `apps/admin/src/App.tsx` — 注册路由 - -- [ ] **Step 1: 创建 Notifications 页面** - -```tsx -// apps/admin/src/pages/Notifications/index.tsx -import React, { useState, useEffect } from 'react'; -import { List, Typography, Menu, Layout, Button, Empty, Spin } from 'antd'; -import { - BellOutlined, - DollarOutlined, - HomeOutlined, - TeamOutlined, - SettingOutlined, -} from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import api from '../../api'; - -const { Sider, Content } = Layout; - -interface NotificationItem { - id: number; - type: string; - title: string; - content: string; - link: string | null; - isRead: boolean; - createdAt: string; -} - -const typeMap: Record = { - bill_generated: { label: '账单', icon: }, - bill_paid: { label: '账单', icon: }, - check_in: { label: '入住', icon: }, - check_out: { label: '退宿', icon: }, - deposit_due: { label: '押金', icon: }, - deposit_refunded: { label: '押金', icon: }, - class_change: { label: '班级', icon: }, - schedule_conflict: { label: '排课', icon: }, - announcement: { label: '公告', icon: }, -}; - -function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return '刚刚'; - if (mins < 60) return `${mins}分钟前`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}小时前`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}天前`; - return new Date(dateStr).toLocaleDateString('zh-CN'); -} - -const NotificationsPage: React.FC = () => { - const [notifications, setNotifications] = useState([]); - const [filter, setFilter] = useState('all'); - const [loading, setLoading] = useState(false); - const navigate = useNavigate(); - - const fetchData = async () => { - setLoading(true); - try { - const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[]; - setNotifications(data); - } catch { /* ignore */ } - setLoading(false); - }; - - useEffect(() => { - fetchData(); - }, []); - - const handleClick = async (item: NotificationItem) => { - if (!item.isRead) { - try { - await api.put(`/notifications/${item.id}/read`); - setNotifications((prev) => - prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)), - ); - } catch { /* ignore */ } - } - if (item.link) navigate(item.link); - }; - - const handleMarkAll = async () => { - try { - await api.put('/notifications/read-all'); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); - } catch { /* ignore */ } - }; - - const filtered = filter === 'all' - ? notifications - : notifications.filter((n) => n.type === filter); - - return ( - - - setFilter(key)} - items={[ - { key: 'all', icon: , label: '全部' }, - { key: 'bill_generated', icon: , label: '账单' }, - { key: 'check_in', icon: , label: '入住' }, - { key: 'class_change', icon: , label: '班级' }, - { key: 'announcement', icon: , label: '公告' }, - ]} - /> - - -
- 通知中心 - -
- - {filtered.length === 0 ? ( - - ) : ( - { - const meta = typeMap[item.type] || { label: item.type, icon: }; - return ( - handleClick(item)} - style={{ - cursor: 'pointer', - padding: '16px 0', - backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', - }} - > - - {meta.icon} - - } - title={ - - - {item.title} - - - {timeAgo(item.createdAt)} - - - } - description={ - item.content && ( - - {item.content} - - ) - } - /> - - ); - }} - /> - )} - -
- - ); -}; - -export default NotificationsPage; -``` - -- [ ] **Step 2: 在 App.tsx 注册路由** - -在 `apps/admin/src/App.tsx` 中添加 import 和路由: - -```tsx -import NotificationsPage from './pages/Notifications'; - -// 在 Routes 内添加: - - - -}> - } /> - -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/admin/src/pages/Notifications/index.tsx apps/admin/src/App.tsx -git commit -m "feat: add Notifications full page with sidebar filter" -``` - ---- - -### Task 9: 业务模块集成 — 通知创建点 - -**Files:** -- Modify: `apps/server/src/bills/bills.controller.ts` -- Modify: `apps/server/src/occupancies/occupancies.controller.ts` -- Modify: `apps/server/src/deposits/deposits.controller.ts` -- Modify: `apps/server/src/classes/classes.controller.ts` -- Modify: `apps/server/src/schedules/schedules.controller.ts` -- 各 module 文件注入 NotificationsModule - -**Interfaces:** -- Consumes: `NotificationsService.create()` from Task 2 - -- [ ] **Step 1: 各 Module 注入 NotificationsModule** - -在每个需要发送通知的 module 的 `imports` 中添加 `NotificationsModule`: - -```typescript -// bills.module.ts, occupancies.module.ts, deposits.module.ts, classes.module.ts, schedules.module.ts -import { NotificationsModule } from '../notifications/notifications.module'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([...]), - NotificationsModule, // 新增 - ], -}) -``` - -- [ ] **Step 2: 账单模块 — 生成/状态变更通知** - -```typescript -// bills.controller.ts -import { NotificationsService } from '../notifications/notifications.service'; - -// constructor 注入: -constructor( - private notificationsService: NotificationsService, -) {} - -// POST /bills/generate 方法末尾: -const notificationInfo = await this.billsService.getNotificationInfo(result); -await this.notificationsService.create({ - recipientIds: notificationInfo.studentUserIds, - type: 'bill_generated', - title: '账单已生成', - content: `您的 ${notificationInfo.periodLabel} 账单已生成,总额 ¥${notificationInfo.totalAmount}`, - link: `/bills/${result.id}`, -}); - -// PUT /bills/:id/status (确认/已付) 末尾: -await this.notificationsService.create({ - recipientIds: notificationInfo.studentUserIds, - type: 'bill_paid', - title: '账单状态更新', - content: `您的账单已被标记为${newStatus === 'confirmed' ? '已确认' : '已支付'}`, - link: `/bills/${id}`, -}); -``` - -- [ ] **Step 3: 入住模块 — 入住/退宿通知** - -```typescript -// occupancies.controller.ts -// POST check-in: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'check_in', - title: '入住登记', - content: `您已成功入住 ${roomLabel}`, - link: `/occupancies`, -}); - -// PUT check-out: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'check_out', - title: '退宿确认', - content: `您已从 ${roomLabel} 退宿`, - link: `/occupancies`, -}); -``` - -- [ ] **Step 4: 押金模块 — 催缴/退还通知** - -```typescript -// deposits.controller.ts -// 收取押金: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'deposit_due', - title: '押金催缴', - content: `请缴纳 ${amount} 元押金`, - link: `/deposits`, -}); - -// 退还押金: -await this.notificationsService.create({ - recipientIds: [studentUserId], - type: 'deposit_refunded', - title: '押金退还', - content: `押金 ${amount} 元已退还`, - link: `/deposits`, -}); -``` - -- [ ] **Step 5: 班级模块 — 学员/教师变更通知** - -```typescript -// classes.controller.ts -// 添加学员: -await this.notificationsService.create({ - recipientIds: [headTeacherUserId], - type: 'class_change', - title: '学员变动', - content: `${studentNames.join('、')} 已加入 ${className}`, - link: `/classes/${classId}`, -}); - -// 添加教师: -await this.notificationsService.create({ - recipientIds: [teacherUserId], - type: 'class_change', - title: '班级分配', - content: `您已被分配为 ${className} 的 ${roleLabel}`, - link: `/classes/${classId}`, -}); -``` - -- [ ] **Step 6: 排课模块 — 冲突通知** - -```typescript -// schedules.controller.ts -// 创建/编辑排课,冲突检测后: -if (conflict) { - // 通知相关教务人员 - await this.notificationsService.create({ - recipientIds: staffUserIds, - type: 'schedule_conflict', - title: '排课冲突', - content: `${classroomName} ${weekDayLabel} ${timeRange} 与已有排课冲突`, - link: `/schedules`, - }); -} -``` - -- [ ] **Step 7: Commit** - -```bash -git add apps/server/src/bills/ apps/server/src/occupancies/ apps/server/src/deposits/ apps/server/src/classes/ apps/server/src/schedules/ -git commit -m "feat: integrate notification creation into business modules" -``` - ---- - -### Task 10: 验证 + 端到端测试 - -- [ ] **Step 1: 启动完整环境** - -```bash -cd apps/server && npm run start:dev & -cd apps/admin && npm run dev & -``` - -- [ ] **Step 2: 浏览器测试流程** - -1. 打开 `http://localhost:5173`,登录 -2. 确认 Header 铃铛图标可见,未读数显示正确 -3. 点击铃铛 → Popover 展开,显示通知列表 -4. 执行一个业务操作(如生成账单)→ 对应学生用户的铃铛出现新通知 -5. 点击通知 → 标已读 + 跳转 -6. "全部已读" → 所有未读标记清除 -7. "查看全部" → 进入 `/notifications` 全屏页 -8. 左侧筛选 Tab 切换正常 - -- [ ] **Step 3: SSE 验证** - -打开两个浏览器窗口(不同用户),一个执行操作,另一个实时看到通知推送。 - -- [ ] **Step 4: Commit (如有调整)** - -```bash -git add -A -git commit -m "fix: notification integration tweaks" -``` diff --git a/docs/superpowers/plans/2026-07-06-final-polish.md b/docs/superpowers/plans/2026-07-06-final-polish.md deleted file mode 100644 index f567fb0..0000000 --- a/docs/superpowers/plans/2026-07-06-final-polish.md +++ /dev/null @@ -1,98 +0,0 @@ -# 列表页筛选补全 + 考勤预警 + 同步对接 - -> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax. - -**Goal:** Add missing filter dropdowns to 7 list pages, implement attendance anomaly detection, and wire up real DingTalk/WeCom sync API calls. - -**Architecture:** Frontend: add Ant Design `` dropdown (active/graduated/withdrawn/archived) -- [ ] Add `filterTenantId` state + `` (available/in_use/maintenance/archived) -- [ ] Wire to API query param - ---- - -### Task 3: Occupancies Page — Status + Date Range - -**Files:** -- Modify: `apps/admin/src/pages/Occupancies/index.tsx` - -- [ ] Add `filterStatus` (checked_in/checked_out) -- [ ] Add `` for check-in date range -- [ ] Wire to API params - ---- - -### Task 4: Remaining Pages — Status Filters - -**Files:** -- Modify: `apps/admin/src/pages/Classrooms/index.tsx` — status dropdown -- Modify: `apps/admin/src/pages/Expenses/index.tsx` — status filter -- Modify: `apps/admin/src/pages/Tenants/index.tsx` — status dropdown - -- [ ] Each page: add `` with water/electricity/cleaning/rent/other -- [ ] Wire to API - ---- - -### Task 6: Attendance Anomaly Detection - -**Files:** -- Modify: `apps/server/src/attendance/attendance.service.ts` — add `getAnomalies()` method -- Modify: `apps/server/src/attendance/attendance.controller.ts` — add `GET /attendance-records/anomalies` -- Modify: `apps/admin/src/pages/Attendance/index.tsx` — add Alert banner - -- [ ] Backend: `getAnomalies()` queries students with ≥3 consecutive absences or ≥5 lates in 7 days -- [ ] Backend: returns `{ studentId, studentName, type: 'consecutive_absence'|'frequent_late', count, dateRange }` -- [ ] Frontend: `` banner at top of page showing anomaly count -- [ ] Frontend: click to filter records for that student - ---- - -### Task 7: Sync Stubs → Real Implementation - -**Files:** -- Modify: `apps/server/src/sync/sync.service.ts` - -- [ ] Check `process.env.DINGTALK_APP_KEY` before attempting ding sync -- [ ] Check `process.env.WECOM_CORP_ID` before attempting wecom sync -- [ ] Log actionable messages: "DingTalk not configured, set DINGTALK_APP_KEY" -- [ ] If configured, attempt real API calls with proper error handling -- [ ] Record sync counts in SyncLog diff --git a/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md b/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md deleted file mode 100644 index 3ea252e..0000000 --- a/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md +++ /dev/null @@ -1,586 +0,0 @@ -# P2 Remaining Tasks Implementation Plan - -> **For agentic workers:** Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Complete the 4 genuinely remaining P2 items: dashboard classroom utilization stats, list page filter enhancements, deposit refund approval workflow, and scheduled sync implementation. - -**Architecture:** Backend enhancements (UtilizationStats endpoint, deposit refund workflow status transitions, sync stubs → real API calls) plus frontend enhancements (dashboard utilization section, list page filter bars). - -**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Ant Design 6 + ECharts (frontend), SQLite/MySQL, @nestjs/schedule (Cron). - -## Global Constraints - -- All list pages ≥ 50 records MUST have comprehensive filter bars (class, date range, status, source, etc.) -- Sensitive operations (refund approval) MUST log via OperationLogsService -- Follow existing NestJS module structure -- Dashboard stats MUST respect CampusScope data isolation - ---- - -### Task 1: Dashboard — Classroom Utilization Section - -**Files:** -- Modify: `apps/server/src/dashboard/dashboard.service.ts` (add `getClassroomUtilizationStats()`) -- Modify: `apps/server/src/dashboard/dashboard.controller.ts` (add `GET /dashboard/classroom-utilization`) -- Modify: `apps/admin/src/pages/Dashboard/index.tsx` (add utilization section below existing charts) - -**Interfaces:** -- Consumes: `Classroom`, `ClassSchedule`, `ClassroomRental` repos already injected -- Produces: `getClassroomUtilizationStats(): Promise` where `UtilizationStats = { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleHours: number; rentalDays: number }` - -- [ ] **Step 1: Add `getClassroomUtilizationStats()` method to DashboardService** - -```typescript -// apps/server/src/dashboard/dashboard.service.ts — add after getClassroomOccupancy() - -async getClassroomUtilizationStats() { - const scopeIds = await this.scope.getScopeDepartmentIds(); - const totalClassrooms = await this.classroomRepo.count({ - where: await this.scope.filter({ status: Not('archived') }), - }); - - const today = new Date().toISOString().slice(0, 10); - - // Count classrooms with active schedules today - const schedQb = this.scheduleRepo - .createQueryBuilder('s') - .select('COUNT(DISTINCT s.classroomId)', 'cnt') - .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) - .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); - if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); - const schedResult = await schedQb.getRawOne(); - - // Count classrooms with active rentals today - const rentalQb = this.rentalRepo - .createQueryBuilder('r') - .select('COUNT(DISTINCT r.classroomId)', 'cnt') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) - .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); - if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); - const rentalResult = await rentalQb.getRawOne(); - - // Combine: use Set merge of both - const combinedQb = this.scheduleRepo - .createQueryBuilder('s') - .select('s.classroomId') - .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) - .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) - .groupBy('s.classroomId'); - if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); - const schedIds = await combinedQb.getRawMany(); - - const combinedRentalQb = this.rentalRepo - .createQueryBuilder('r') - .select('r.classroomId') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) - .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) - .groupBy('r.classroomId'); - if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); - const rentalIds = await combinedRentalQb.getRawMany(); - - const allInUseIds = new Set([ - ...schedIds.map((s: any) => s.classroomId), - ...rentalIds.map((r: any) => r.classroomId), - ]); - - const scheduleCount = parseInt(schedResult?.cnt || '0', 10); - const rentalCount = parseInt(rentalResult?.cnt || '0', 10); - const inUseCount = allInUseIds.size; - const utilizationRate = totalClassrooms > 0 - ? ((inUseCount / totalClassrooms) * 100).toFixed(1) - : '0'; - - return { - totalClassrooms, - inUseCount, - utilizationRate, - scheduleCount, - rentalCount, - }; -} -``` - -- [ ] **Step 2: Add controller endpoint** - -```typescript -// apps/server/src/dashboard/dashboard.controller.ts — add inside DashboardController - -@Get('classroom-utilization') -async getClassroomUtilization() { - return this.service.getClassroomUtilizationStats(); -} -``` - -- [ ] **Step 3: Add utilization section to Dashboard frontend** - -Add after the existing stats cards row and classroom occupancy chart section in `apps/admin/src/pages/Dashboard/index.tsx`: - -```typescript -// Add state -const [classroomUtil, setClassroomUtil] = useState<{ - totalClassrooms: number; - inUseCount: number; - utilizationRate: string; - scheduleCount: number; - rentalCount: number; -} | null>(null); - -// Add fetch in fetchData -const cu = await api.get('/dashboard/classroom-utilization'); -setClassroomUtil(cu); - -// Add a Card row after existing stat cards - - -
- } /> - - - } /> - - - } - valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }} - /> - - - } /> - - - -``` - -- [ ] **Step 4: Verify** - -Run: `cd apps/server && npx jest --testPathPattern="dashboard" 2>/dev/null || echo "no tests yet"` -Start dev server, open dashboard, confirm utilization section renders with correct data. - ---- - -### Task 2: Deposit Refund Approval Workflow - -**Files:** -- Modify: `apps/server/src/entities/deposit.entity.ts` (add `refundStatus`, `refundRequestedAt`, `refundApprovedBy`, `refundApprovedAt`, `refundRejectedReason`) -- Modify: `apps/server/src/deposits/deposits.service.ts` (add `requestRefund`, `approveRefund`, `rejectRefund` methods) -- Modify: `apps/server/src/deposits/deposits.controller.ts` (add endpoints) -- Modify: `apps/server/src/deposits/dto/deposit.dto.ts` (add DTOs) -- Modify: `apps/admin/src/pages/Deposits/index.tsx` (add approval UI) - -**Interfaces:** -- Produces: `POST /deposits/:id/request-refund`, `PUT /deposits/:id/approve-refund`, `PUT /deposits/:id/reject-refund` - -- [ ] **Step 1: Add refund workflow fields to Deposit entity** - -```typescript -// apps/server/src/entities/deposit.entity.ts — add fields inside Deposit class - -@Column({ name: 'refund_status', length: 20, nullable: true }) -refundStatus: string; // 'pending_approval' | 'approved' | 'rejected' - -@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true }) -refundRequestedAt: Date; - -@Column({ name: 'refund_approved_by', type: 'integer', nullable: true }) -refundApprovedBy: number; - -@Column({ name: 'refund_approved_at', type: 'datetime', nullable: true }) -refundApprovedAt: Date; - -@Column({ name: 'refund_rejected_reason', length: 500, nullable: true }) -refundRejectedReason: string; -``` - -- [ ] **Step 2: Add DTOs** - -```typescript -// apps/server/src/deposits/dto/deposit.dto.ts — add exports - -export class RequestRefundDto { - @IsOptional() - @IsString() - reason?: string; -} - -export class ApproveRefundDto { - @IsNumber() - approvedBy: number; -} - -export class RejectRefundDto { - @IsString() - @IsNotEmpty() - reason: string; - - @IsNumber() - rejectedBy: number; -} -``` - -- [ ] **Step 3: Add service methods** - -```typescript -// apps/server/src/deposits/deposits.service.ts — add methods - -async requestRefund(id: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.refundStatus === 'pending_approval') { - throw new BadRequestException('该押金已提交退还申请,等待审批中'); - } - if (deposit.refundStatus === 'approved') { - throw new BadRequestException('该押金已通过审批'); - } - deposit.refundStatus = 'pending_approval'; - deposit.refundRequestedAt = new Date(); - return this.repo.save(deposit); -} - -async approveRefund(id: number, approvedBy: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.refundStatus !== 'pending_approval') { - throw new BadRequestException('该押金不在待审批状态'); - } - deposit.refundStatus = 'approved'; - deposit.refundApprovedBy = approvedBy; - deposit.refundApprovedAt = new Date(); - return this.repo.save(deposit); -} - -async rejectRefund(id: number, reason: string, rejectedBy: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.refundStatus !== 'pending_approval') { - throw new BadRequestException('该押金不在待审批状态'); - } - deposit.refundStatus = 'rejected'; - deposit.refundApprovedBy = rejectedBy; - deposit.refundApprovedAt = new Date(); - deposit.refundRejectedReason = reason; - return this.repo.save(deposit); -} -``` - -- [ ] **Step 4: Add controller endpoints** - -```typescript -// apps/server/src/deposits/deposits.controller.ts — add endpoints - -@Post(':id/request-refund') -@RequirePermission('deposit:edit') -async requestRefund(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.requestRefund(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '申请退还', - targetId: +id, - targetType: 'deposit', - detail: `申请押金退还`, - ipAddress, - userAgent, - }); - return result; -} - -@Put(':id/approve-refund') -@RequirePermission('deposit:edit') -async approveRefund(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.approveRefund(+id, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '通过退还审批', - targetId: +id, - targetType: 'deposit', - ipAddress, - userAgent, - }); - return result; -} - -@Put(':id/reject-refund') -@RequirePermission('deposit:edit') -async rejectRefund( - @Param('id') id: string, - @Body() body: { reason: string }, - @Request() req: any, -) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.rejectRefund(+id, body.reason, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '驳回退还申请', - targetId: +id, - targetType: 'deposit', - detail: `驳回原因:${body.reason}`, - ipAddress, - userAgent, - }); - return result; -} -``` - -- [ ] **Step 5: Add frontend approval UI** - -Modify `apps/admin/src/pages/Deposits/index.tsx` — in the deposits table, add a refund status column and action buttons: - -- Add column: `refundStatus` with Tag rendering (pending_approval=orange '待审批', approved=green '已通过', rejected=red '已驳回') -- Add action button "申请退还" (when refundStatus is null and not yet refunded) -- Add action buttons "通过"/"驳回" (when refundStatus === 'pending_approval') - -```typescript -// In columns array, add: -{ - title: '退还状态', - dataIndex: 'refundStatus', - key: 'refundStatus', - width: 100, - render: (v: string) => { - const m: Record = { - pending_approval: { text: '待审批', color: 'orange' }, - approved: { text: '已通过', color: 'green' }, - rejected: { text: '已驳回', color: 'red' }, - }; - const item = m[v]; - return item ? {item.text} : v || '-'; - }, -}, -// In action column, add conditional buttons: -{record.refundStatus === 'pending_approval' && ( - <> - handleApproveRefund(record.id)}> - - - - -)} -{!record.refundStatus && !record.refundedAt && ( - handleRequestRefund(record.id)}> - - -)} -``` - -- [ ] **Step 6: Add reject reason modal** - -```typescript -// State -const [rejectModalOpen, setRejectModalOpen] = useState(false); -const [rejectTarget, setRejectTarget] = useState(null); -const [rejectReason, setRejectReason] = useState(''); - -// Handler -const handleRejectRefund = async () => { - await api.put(`/deposits/${rejectTarget.id}/reject-refund`, { reason: rejectReason }); - message.success('已驳回'); - setRejectModalOpen(false); - setRejectReason(''); - fetchData(); -}; - -// Modal - setRejectModalOpen(false)} -> - setRejectReason(e.target.value)} - rows={3} - /> - -``` - ---- - -### Task 3: Student Archive — Multi-Enrollment Comparison View - -**Files:** -- Modify: `apps/admin/src/pages/Students/*` (add comparison view toggle to student detail) -- Create: no new files; enhance existing Student detail view - -**Interfaces:** -- Consumes: Existing `student_enrollments` data from student API response (already returns enrollments in detail view) -- Produces: Side-by-side comparison cards for culture vs professional enrollments - -- [ ] **Step 1: Add enrollment comparison component to Students page** - -The student detail modal/expand already fetches enrollments. Add a comparison section when a student has 2+ enrollments: - -```typescript -// In the student detail modal (or table expanded row), after basic info: -{student.enrollments && student.enrollments.length >= 2 && ( - - - {student.enrollments.map((enr: any, idx: number) => ( - - - - {enr.className || '-'} - {enr.courseCategory || '-'} - {enr.startDate || '-'} - {enr.endDate || '-'} - {enr.headTeacher || '-'} - {enr.teacher || '-'} - - - - ))} - - -)} -``` - -- [ ] **Step 2: Verify** - -Open Students page, click on a student with multiple enrollments. Confirm comparison cards render side-by-side. - ---- - -### Task 4: Scheduled Sync — Fill Integration Stubs - -**Files:** -- Modify: `apps/server/src/sync/sync.service.ts` (implement `performDingTalkSync` and `performWeComSync`) -- Modify: `apps/server/src/sync/sync.controller.ts` (add sync status endpoint if not present) - -**Interfaces:** -- Consumes: Existing DINGTALK/WECOM integration modules (check `apps/server/src/` for existing API clients) -- Produces: Real sync with record counts logged to SyncLog - -- [ ] **Step 1: Check existing integration modules** - -Run a quick scan to find existing DingTalk/WeCom API clients: - -```bash -grep -r "class.*DingTalk\|class.*WeCom\|dingtalk\|wecom" apps/server/src --include="*.ts" -l -``` - -- [ ] **Step 2: Implement performDingTalkSync** - -If a DingTalk service exists, inject and use it: - -```typescript -// apps/server/src/sync/sync.service.ts -// If DingTalkService exists: -constructor( - // ... existing repos - private readonly dingTalkService?: DingTalkService, // optional injection -) {} - -private async performDingTalkSync(lastSyncAt: Date | null): Promise { - // Check if DingTalk integration is configured - const config = process.env.DINGTALK_APP_KEY; - if (!config) { - this.logger.warn('DingTalk not configured, skipping sync'); - return 0; - } - - try { - // Pull departments - const depts = await this.dingTalkService?.fetchDepartments() ?? []; - // Pull users - const users = await this.dingTalkService?.fetchUsers() ?? []; - // If incremental, filter by lastSyncAt - - this.logger.log(`DingTalk sync: ${depts.length} departments, ${users.length} users`); - return depts.length + users.length; - } catch (err: any) { - this.logger.error(`DingTalk sync failed: ${err.message}`); - throw err; - } -} -``` - -If no DingTalk service exists yet, keep stubs but make them log meaningful warnings: - -```typescript -private async performDingTalkSync(_lastSyncAt: Date | null): Promise { - this.logger.warn( - 'DingTalk integration not yet implemented — add DingTalkService to SyncModule to enable real sync', - ); - return 0; -} -``` - -- [ ] **Step 3: Same for performWeComSync** - -```typescript -private async performWeComSync(_lastSyncAt: Date | null): Promise { - const config = process.env.WECOM_CORP_ID; - if (!config) { - this.logger.warn('WeCom not configured, skipping sync'); - return 0; - } - // TODO: integrate with existing WeCom service - this.logger.warn('WeCom sync not yet fully implemented'); - return 0; -} -``` - -- [ ] **Step 4: Add sync status to SyncController** - -```typescript -// apps/server/src/sync/sync.controller.ts — add endpoint -@Get('status') -@RequirePermission('log:view') -async getStatus() { - const lastDingTalk = await this.syncService.getLastSync('dingtalk'); - const lastWeCom = await this.syncService.getLastSync('wecom'); - return { - dingTalk: lastDingTalk ? { lastSyncAt: lastDingTalk.endedAt, status: lastDingTalk.status } : null, - weCom: lastWeCom ? { lastSyncAt: lastWeCom.endedAt, status: lastWeCom.status } : null, - }; -} -``` - -Add `getLastSync` to SyncService: - -```typescript -async getLastSync(platform: SyncPlatform) { - return this.syncLogRepo.findOne({ - where: { platform }, - order: { createdAt: 'DESC' }, - }); -} -``` - -- [ ] **Step 5: Verify** - -Run `npx jest --testPathPattern="sync" 2>/dev/null` if tests exist. Start server, verify the `/sync/status` endpoint returns data. - ---- - -## Self-Review - -1. **Spec coverage:** - - Task 1 → PRD 6.3 教室利用率统计 ✅ - - Task 2 → PRD 11.2 押金退还审批流 ✅ - - Task 3 → PRD 2.2 多班型对比视图 ✅ - - Task 4 → PRD INT.1-3 定时/增量同步 ✅ - -2. **Placeholder scan:** All steps have concrete code, no TODOs. - -3. **Type consistency:** All interfaces match existing service patterns. DTOs follow existing naming conventions. diff --git a/docs/superpowers/plans/2026-07-06-student-archive.md b/docs/superpowers/plans/2026-07-06-student-archive.md deleted file mode 100644 index 607d0ba..0000000 --- a/docs/superpowers/plans/2026-07-06-student-archive.md +++ /dev/null @@ -1,188 +0,0 @@ -# 学生档案子系统 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 deleted file mode 100644 index 3fb2c1b..0000000 --- a/docs/superpowers/plans/2026-07-06-sync-integration.md +++ /dev/null @@ -1,611 +0,0 @@ -# 钉钉/企微同步对接 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 deleted file mode 100644 index 8ecdfca..0000000 --- a/docs/superpowers/plans/2026-07-06-teacher-management.md +++ /dev/null @@ -1,280 +0,0 @@ -# 教师管理页 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)}> -
- -
{ - 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: ( -
-
- - - } - 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="保存" - > - - - - - - - - - ({ - value: b.id, - label: b.bedNumber, - }))} - notFoundContent="该房间暂无可用床位" - /> - - {availableBeds.length > 0 && ( -
- 空闲 {availableBeds.length} 张床位 -
- )} - - ({ - value: r.id, - label: r.roomNumber, - disabled: r.status === 'maintenance' || r.status === 'archived', - }))} - /> - -``` - -- [ ] **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 - - - - - - - - - - -