Files
gongxue-base/docs/superpowers/plans/2026-07-05-gongxue-p0-plan.md

54 KiB
Raw Blame History

恭学教育 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 一致:studentsroomsclassroomsbills
  • 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

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 中添加:

export { Class, ClassType, ClassStatus } from './class.entity';
  • Step 3: 创建 class-student.entity.ts
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 中注册
export { ClassStudent } from './class-student.entity';
  • Step 5: 创建 class-teacher.entity.ts
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 中注册
export { ClassTeacher, TeacherRoleType } from './class-teacher.entity';
  • Step 7: 验证 — 启动后端检查 TypeORM 自动建表
cd apps/server && npm run start:dev

Expected: 启动成功,classes/class_student/class_teacher 三张表自动创建。

  • Step 8: Commit
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

// 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
// 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<Class>,
    @InjectRepository(ClassStudent)
    private classStudentRepo: Repository<ClassStudent>,
    @InjectRepository(ClassTeacher)
    private classTeacherRepo: Repository<ClassTeacher>,
  ) {}

  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
// 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: 验证 — 编译通过
cd apps/server && npx tsc --noEmit

Expected: 无类型错误。

  • Step 5: Commit
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

// 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 区域添加:

import { ClassesModule } from './classes/classes.module';

imports 数组中追加:

ClassesModule,
  • Step 3: 验证 — 启动后端测试 API
cd apps/server && npm run start:dev

用 curl 测试(需先获取 JWT token

# 创建班级
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: 返回创建的班级 JSONidstudentCount: 0

  • Step 4: Commit
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: 创建列表页

// 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<string, { color: string; text: string }> = {
  enrolling: { color: 'blue', text: '招生中' },
  active: { color: 'green', text: '在读' },
  ended: { color: 'default', text: '结课' },
  suspended: { color: 'orange', text: '停课' },
};

const TYPE_MAP: Record<string, string> = {
  culture: '文化课',
  professional: '专业课',
  bootcamp: '集训营',
  sprint: '冲刺营',
};

const ClassesPage: React.FC = () => {
  const navigate = useNavigate();
  const [data, setData] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const [modalOpen, setModalOpen] = useState(false);
  const [editing, setEditing] = useState<any>(null);
  const [searchText, setSearchText] = useState('');
  const [filterStatus, setFilterStatus] = useState<string>();
  const [filterType, setFilterType] = useState<string>();
  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) => <Tag>{TYPE_MAP[v] || v}</Tag> },
    {
      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 <Tag color={cfg.color}>{cfg.text}</Tag>;
      },
    },
    {
      title: '操作', width: 200,
      render: (_: any, r: any) => (
        <Space>
          <Button size="small" icon={<TeamOutlined />} onClick={() => navigate(`/classes/${r.id}`)}>详情</Button>
          <PermissionButton permission="class:edit" size="small" onClick={() => handleEdit(r)}>编辑</PermissionButton>
          <Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
            <PermissionButton permission="class:delete" size="small" danger>删除</PermissionButton>
          </Popconfirm>
        </Space>
      ),
    },
  ];

  return (
    <Card>
      <Space style={{ marginBottom: 16 }} wrap>
        <Input
          placeholder="搜索名称/编码"
          prefix={<SearchOutlined />}
          value={searchText}
          onChange={(e) => setSearchText(e.target.value)}
          style={{ width: 200 }}
        />
        <Select
          placeholder="班型" allowClear style={{ width: 120 }}
          value={filterType} onChange={setFilterType}
          options={Object.entries(TYPE_MAP).map(([k, v]) => ({ value: k, label: v }))}
        />
        <Select
          placeholder="状态" allowClear style={{ width: 120 }}
          value={filterStatus} onChange={setFilterStatus}
          options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))}
        />
        <PermissionButton permission="class:create" type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
          创建班级
        </PermissionButton>
      </Space>
      <Table columns={columns} dataSource={filtered} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} />

      <Modal
        title={editing ? '编辑班级' : '创建班级'}
        open={modalOpen} onOk={handleSubmit} onCancel={() => setModalOpen(false)}
        width={600}
      >
        <Form form={form} layout="vertical">
          <Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
            <Input />
          </Form.Item>
          <Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
            <Input />
          </Form.Item>
          <Form.Item name="classType" label="班型" rules={[{ required: true }]}>
            <Select options={Object.entries(TYPE_MAP).map(([k, v]) => ({ value: k, label: v }))} />
          </Form.Item>
          <Space>
            <Form.Item name="startDate" label="开班日期">
              <DatePicker />
            </Form.Item>
            <Form.Item name="endDate" label="结课日期">
              <DatePicker />
            </Form.Item>
            <Form.Item name="maxStudents" label="人数上限">
              <InputNumber min={1} />
            </Form.Item>
          </Space>
          <Form.Item name="status" label="状态" initialValue="enrolling">
            <Select options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))} />
          </Form.Item>
          <Form.Item name="notes" label="备注">
            <Input.TextArea rows={3} />
          </Form.Item>
        </Form>
      </Modal>
    </Card>
  );
};

export default ClassesPage;
  • Step 2: 在 App.tsx 添加路由

apps/admin/src/App.tsx 的 import 区添加:

import ClassesPage from './pages/Classes';

<Routes> 内添加:

<Route path="/classes" element={<PermissionRoute permission="class:view"><ClassesPage /></PermissionRoute>} />
<Route path="/classes/:id" element={<PermissionRoute permission="class:view"><ClassDetailPage /></PermissionRoute>} />

注意ClassDetailPage 暂时以占位组件引入,下一个任务实现。

  • Step 3: 在 MainLayout.tsx 添加菜单项

在菜单数组中加入:

{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },

注意:确保 TeamOutlined 已从 @ant-design/icons 导入。

  • Step 4: 验证 — 启动前端查看页面
cd apps/admin && npm run dev

打开浏览器访问 /classes,验证:表格渲染、筛选功能、创建/编辑弹窗。

  • Step 5: Commit
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: 创建详情页

// 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<string, { color: string; text: string }> = {
  enrolling: { color: 'blue', text: '招生中' },
  active: { color: 'green', text: '在读' },
  ended: { color: 'default', text: '结课' },
  suspended: { color: 'orange', text: '停课' },
};

const TYPE_MAP: Record<string, string> = {
  culture: '文化课', professional: '专业课', bootcamp: '集训营', sprint: '冲刺营',
};

const ROLE_MAP: Record<string, string> = {
  subject_teacher: '任课老师', head_teacher: '班主任', life_teacher: '生活老师', academic_teacher: '学服老师',
};

const ClassDetailPage: React.FC = () => {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const [detail, setDetail] = useState<any>(null);
  const [students, setStudents] = useState<any[]>([]);
  const [teachers, setTeachers] = useState<any[]>([]);
  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<any[]>([]);
  const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);

  // Teacher modal state
  const [teacherModalOpen, setTeacherModalOpen] = useState(false);
  const [allUsers, setAllUsers] = useState<any[]>([]);
  const [teacherRole, setTeacherRole] = useState('subject_teacher');
  const [teacherSubject, setTeacherSubject] = useState('');
  const [selectedTeacherId, setSelectedTeacherId] = useState<number>();

  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) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '在读' : '已离班'}</Tag>,
    },
    {
      title: '操作',
      render: (_: any, r: any) => (
        <Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
          <Button size="small" danger>移除</Button>
        </Popconfirm>
      ),
    },
  ];

  const teacherColumns = [
    { title: '姓名', dataIndex: 'username' },
    {
      title: '角色', dataIndex: 'roleType',
      render: (v: string) => <Tag>{ROLE_MAP[v] || v}</Tag>,
    },
    { title: '科目', dataIndex: 'subject', render: (v: string) => v || '-' },
    {
      title: '操作',
      render: (_: any, r: any) => (
        <Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}>
          <Button size="small" danger>移除</Button>
        </Popconfirm>
      ),
    },
  ];

  return (
    <Card
      title={
        <Space>
          <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/classes')} />
          <span>{detail.name} ({detail.code})</span>
          <Tag color={STATUS_MAP[detail.status]?.color}>{STATUS_MAP[detail.status]?.text}</Tag>
        </Space>
      }
      loading={loading}
    >
      <Tabs defaultActiveKey="info" items={[
        {
          key: 'info', label: '基本信息',
          children: (
            <div>
              {editingInfo ? (
                <Form form={editForm} layout="vertical" initialValues={{ ...detail, startDate: detail.startDate ? dayjs(detail.startDate) : undefined, endDate: detail.endDate ? dayjs(detail.endDate) : undefined }}>
                  <Space wrap>
                    <Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
                    <Form.Item name="code" label="编码"><Input /></Form.Item>
                    <Form.Item name="classType" label="班型"><Select options={Object.entries(TYPE_MAP).map(([k,v]) => ({value:k,label:v}))} /></Form.Item>
                    <Form.Item name="startDate" label="开班"><DatePicker /></Form.Item>
                    <Form.Item name="endDate" label="结课"><DatePicker /></Form.Item>
                    <Form.Item name="maxStudents" label="人数上限"><InputNumber min={1} /></Form.Item>
                    <Form.Item name="status" label="状态"><Select options={Object.entries(STATUS_MAP).map(([k,v]) => ({value:k,label:v.text}))} /></Form.Item>
                  </Space>
                  <Form.Item name="notes" label="备注"><Input.TextArea rows={3} /></Form.Item>
                  <Space>
                    <Button type="primary" onClick={handleSaveInfo}>保存</Button>
                    <Button onClick={() => setEditingInfo(false)}>取消</Button>
                  </Space>
                </Form>
              ) : (
                <div>
                  <Descriptions column={3} bordered size="small">
                    <Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
                    <Descriptions.Item label="开班日期">{detail.startDate || '-'}</Descriptions.Item>
                    <Descriptions.Item label="结课日期">{detail.endDate || '-'}</Descriptions.Item>
                    <Descriptions.Item label="学员">{detail.studentCount}/{detail.maxStudents || '-'}</Descriptions.Item>
                    <Descriptions.Item label="班主任">{teachers.find((t:any) => t.roleType === 'head_teacher')?.username || '-'}</Descriptions.Item>
                    <Descriptions.Item label="备注">{detail.notes || '-'}</Descriptions.Item>
                  </Descriptions>
                  <PermissionButton permission="class:edit" style={{ marginTop: 16 }} onClick={() => { editForm.setFieldsValue(detail); setEditingInfo(true); }}>编辑</PermissionButton>
                </div>
              )}
            </div>
          ),
        },
        {
          key: 'students', label: `花名册 (${students.filter((s:any) => s.status === 'active').length})`,
          children: (
            <div>
              <Button icon={<PlusOutlined />} type="primary" onClick={openStudentModal} style={{ marginBottom: 16 }}>添加学员</Button>
              <Table columns={studentColumns} dataSource={students} rowKey="id" pagination={{ pageSize: 20 }} />
              <Modal title="添加学员" open={studentModalOpen} onOk={handleAddStudents} onCancel={() => setStudentModalOpen(false)}>
                <Select
                  mode="multiple"
                  style={{ width: '100%' }}
                  placeholder="选择学员"
                  value={selectedStudentIds}
                  onChange={setSelectedStudentIds}
                  options={allStudents.map((s: any) => ({ value: s.id, label: `${s.name} (${s.studentNo || s.id})` }))}
                  filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
                />
              </Modal>
            </div>
          ),
        },
        {
          key: 'teachers', label: `教师 (${teachers.length})`,
          children: (
            <div>
              <Button icon={<PlusOutlined />} type="primary" onClick={openTeacherModal} style={{ marginBottom: 16 }}>添加教师</Button>
              <Table columns={teacherColumns} dataSource={teachers} rowKey="id" pagination={{ pageSize: 20 }} />
              <Modal title="添加教师" open={teacherModalOpen} onOk={handleAddTeacher} onCancel={() => setTeacherModalOpen(false)}>
                <Space direction="vertical" style={{ width: '100%' }}>
                  <Select
                    style={{ width: '100%' }}
                    placeholder="选择教师"
                    value={selectedTeacherId}
                    onChange={setSelectedTeacherId}
                    options={allUsers.map((u: any) => ({ value: u.id, label: u.username }))}
                    filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
                  />
                  <Select
                    style={{ width: '100%' }}
                    value={teacherRole}
                    onChange={setTeacherRole}
                    options={Object.entries(ROLE_MAP).map(([k, v]) => ({ value: k, label: v }))}
                  />
                  {teacherRole === 'subject_teacher' && (
                    <Input placeholder="任教科目" value={teacherSubject} onChange={(e) => setTeacherSubject(e.target.value)} />
                  )}
                </Space>
              </Modal>
            </div>
          ),
        },
      ]} />
    </Card>
  );
};

export default ClassDetailPage;
  • Step 2: 更新 App.tsx import

确保 App.tsxClassDetailPage 的 import 已添加Task 1.4 中已预留路由)。

  • Step 3: 验证 — 浏览器测试

打开班级列表 → 点击"详情" → 验证基本信息/花名册/教师三个 Tab 渲染和数据加载。

  • Step 4: Commit
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: 创建实体

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 导出
export { ClassSchedule, ScheduleType } from './class-schedule.entity';
  • Step 3: Commit
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:

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
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 中添加:

@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 中添加:

@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 中添加:

@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.tsupdate-room.dto.ts 中添加字段:

@IsOptional() @IsString()
rentalCategory?: string;

@IsOptional() @IsNumber()
monthlyRate?: number;
  • Step 5: 更新 Occupancy DTO

在入住 DTO 中添加:

@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
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

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
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 数组中追加

{ 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_supervisorpermissionGroups 中添加:'class', 'schedule', 'attendance'

  • Step 3: 验证 — 数据库重新 seed

删除 SQLite 数据库后重启后端,确认权限表包含新节点。

rm apps/server/dorm_billing.db && cd apps/server && npm run start:dev
  • Step 4: Commit
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