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

1803 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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