1803 lines
54 KiB
Markdown
1803 lines
54 KiB
Markdown
# 恭学教育 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<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**
|
||
|
||
```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<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 区添加:
|
||
```typescript
|
||
import ClassesPage from './pages/Classes';
|
||
```
|
||
|
||
在 `<Routes>` 内添加:
|
||
```tsx
|
||
<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 添加菜单项**
|
||
|
||
在菜单数组中加入:
|
||
```typescript
|
||
{ key: '/classes', icon: <TeamOutlined />, 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<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**
|
||
|
||
```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**
|