# 恭学教育 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**