feat(task1): restructure directories for turborepo monorepo

- Move backend/ to apps/server/ via git mv
- Move frontend/ to apps/admin/ via git mv
- Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
2026-07-02 15:05:12 +08:00
parent 4704adcba1
commit 46a817503e
137 changed files with 52 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { ClassroomsService } from './classrooms.service';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.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';
import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard)
@Controller('classrooms')
export class ClassroomsController {
constructor(private service: ClassroomsService, private logService: OperationLogsService) {}
@Get()
@RequirePermission('classroom:view')
findAll(@Query('building') building?: string, @Query('roomType') roomType?: string, @Query('includeArchived') includeArchived?: string) {
return this.service.findAll({
building,
roomType,
includeArchived: includeArchived === 'true',
});
}
@Get('template')
@RequirePermission('classroom:view')
async downloadTemplate(@Res() res: Response) {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('教室导入模板');
ws.columns = [
{ header: '教室名', key: 'name', width: 15 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '楼层', key: 'floor', width: 8 },
{ header: '类型', key: 'roomType', width: 10 },
{ header: '容量', key: 'capacity', width: 10 },
{ header: '课程类型', key: 'courseType', width: 16 },
{ header: '负责人', key: 'supervisor', width: 12 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ name: 'A201', building: 'A座', floor: 2, roomType: '大', capacity: 60, courseType: '尊享培优班', supervisor: '张老师' });
ws.addRow({ name: 'B301', building: 'B座', floor: 3, roomType: '次大', capacity: 40, courseType: '专业课集训班', supervisor: '李老师' });
ws.addRow({ name: 'B405', building: 'B座', floor: 4, roomType: '小', capacity: 20, courseType: '', supervisor: '' });
// 说明sheet
const ws2 = workbook.addWorksheet('使用说明');
ws2.columns = [{ header: '说明', key: 'note', width: 80 }];
ws2.getRow(1).font = { bold: true };
[
'1. 教室名必填,建议采用「楼栋+房号」如 A201、B301',
'2. 类型可填 大 / 次大 / 小,为空默认「大」',
'3. 同名教室会自动跳过(不覆盖)',
'4. 课程类型可填尊享培优班、专业课集训班等产品班级',
'5. 负责人为班主任/对接人',
].forEach((note) => ws2.addRow({ note }));
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename=classroom_template.xlsx');
await workbook.xlsx.write(res);
res.end();
}
@Get(':id')
@RequirePermission('classroom:view')
findOne(@Param('id') id: string) {
return this.service.findOne(+id);
}
@Post()
@RequirePermission('classroom:create')
async create(@Body() dto: CreateClassroomDto, @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: 'classroom', detail: dto.name, ipAddress, userAgent });
return result;
}
@Put(':id')
@RequirePermission('classroom:edit')
async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @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: 'classroom', detail: JSON.stringify(dto), ipAddress, userAgent });
return result;
}
@Delete(':id')
@RequirePermission('classroom:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom', ipAddress, userAgent });
return result;
}
@Put(':id/restore')
@RequirePermission('classroom:edit')
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom', ipAddress, userAgent });
return result;
}
@Post('import')
@RequirePermission('classroom:create')
@UseInterceptors(FileInterceptor('file'))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
const ws = workbook.worksheets[0];
const rows: any[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
name: String(row.getCell(1).value || ''),
building: String(row.getCell(2).value || '') || undefined,
floor: Number(row.getCell(3).value) || undefined,
roomType: String(row.getCell(4).value || '') || undefined,
capacity: Number(row.getCell(5).value) || undefined,
courseType: String(row.getCell(6).value || '') || undefined,
supervisor: String(row.getCell(7).value || '') || undefined,
});
});
const result = await this.service.batchImport(rows);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '批量导入', detail: result.message, ipAddress, userAgent });
return result;
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassroomsService } from './classrooms.service';
import { ClassroomsController } from './classrooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental]), OperationLogsModule],
controllers: [ClassroomsController],
providers: [ClassroomsService],
exports: [ClassroomsService],
})
export class ClassroomsModule {}

View File

@@ -0,0 +1,78 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
@Injectable()
export class ClassroomsService {
constructor(
@InjectRepository(Classroom) private repo: Repository<Classroom>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
) {}
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) {
const where: any = {};
if (query?.building) where.building = query.building;
if (query?.roomType) where.roomType = query.roomType;
if (!query?.includeArchived) where.status = Not('archived');
return this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
}
async findOne(id: number) {
const cls = await this.repo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('教室不存在');
return cls;
}
async create(dto: CreateClassroomDto) {
const exists = await this.repo.findOne({ where: { name: dto.name } });
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateClassroomDto) {
await this.findOne(id);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
await this.findOne(id);
// 若存在未结束的租赁订单,不允许归档
const active = await this.rentalRepo.count({ where: { classroomId: id, status: 'active' } });
if (active > 0) throw new BadRequestException('该教室存在进行中的租赁订单,无法归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async restore(id: number) {
const cls = await this.findOne(id);
if (cls.status !== 'archived') throw new BadRequestException('该教室未被归档');
await this.repo.update(id, { status: 'available' });
return { message: '已恢复' };
}
async batchImport(rows: { name: string; building?: string; floor?: number; capacity?: number; roomType?: string; courseType?: string; supervisor?: string }[]) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.name || !row.name.trim()) { skipped++; continue; }
const name = row.name.trim();
const exists = await this.repo.findOne({ where: { name } });
if (exists) { skipped++; continue; }
await this.repo.save(this.repo.create({
name,
building: row.building?.trim() || undefined,
floor: row.floor || undefined,
capacity: row.capacity || 30,
roomType: row.roomType?.trim() || '大',
courseType: row.courseType?.trim() || undefined,
supervisor: row.supervisor?.trim() || undefined,
}));
imported++;
}
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 条(重复或空行)`, imported, skipped };
}
}

View File

@@ -0,0 +1,73 @@
import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator';
export class CreateClassroomDto {
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
building?: string;
@IsOptional()
@IsInt()
floor?: number;
@IsOptional()
@IsInt()
capacity?: number;
@IsOptional()
@IsString()
roomType?: string; // 大 / 次大 / 小
@IsOptional()
@IsString()
courseType?: string;
@IsOptional()
@IsString()
supervisor?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateClassroomDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
building?: string;
@IsOptional()
@IsInt()
floor?: number;
@IsOptional()
@IsInt()
capacity?: number;
@IsOptional()
@IsString()
roomType?: string;
@IsOptional()
@IsString()
courseType?: string;
@IsOptional()
@IsString()
supervisor?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(['available', 'archived'])
status?: string;
}