54 KiB
恭学教育 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:
Classentity 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,ClassTeacherentities from Task 1.1 -
Produces:
ClassesServicewith 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:
ClassesServicefrom Task 1.2,OperationLogsService(global),extractRequestInfofrom 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: 返回创建的班级 JSON,含 id、studentCount: 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.tsx 中 ClassDetailPage 的 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.ts 和 update-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_supervisor 的 permissionGroups 中添加:'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