diff --git a/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md b/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md new file mode 100644 index 0000000..85e125e --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md @@ -0,0 +1,1802 @@ +# 恭学教育 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**