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,53 @@
import { IsString, IsOptional, IsInt, IsEnum, Min } from 'class-validator';
export class CreateRoomDto {
@IsString()
roomNumber: string;
@IsOptional()
@IsString()
building?: string;
@IsOptional()
@IsInt()
floor?: number;
@IsInt()
@Min(1)
capacity: number;
@IsOptional()
@IsString()
roomType?: string;
}
export class UpdateRoomDto {
@IsOptional()
@IsString()
roomNumber?: string;
@IsOptional()
@IsString()
building?: string;
@IsOptional()
@IsInt()
floor?: number;
@IsOptional()
@IsInt()
@Min(1)
capacity?: number;
@IsOptional()
@IsString()
roomType?: string;
@IsOptional()
@IsString()
gender?: string;
@IsOptional()
@IsEnum(['available', 'full', 'maintenance'])
status?: string;
}

View File

@@ -0,0 +1,159 @@
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 { RoomsService } from './rooms.service';
import { CreateRoomDto, UpdateRoomDto } from './dto/room.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('rooms')
export class RoomsController {
constructor(private service: RoomsService, private logService: OperationLogsService) {}
@Get()
@RequirePermission('room:view')
findAll(@Query('building') building?: string, @Query('includeArchived') includeArchived?: string) {
return this.service.findAll({ building, includeArchived: includeArchived === 'true' });
}
@Get('overview')
@RequirePermission('room:view')
getOverview(@Query('includeArchived') includeArchived?: string) {
return this.service.getRoomOverview({ includeArchived: includeArchived === 'true' });
}
@Get('visual')
@RequirePermission('room:view')
getVisual() {
return this.service.getRoomVisual();
}
@Get('template')
@RequirePermission('room:view')
async downloadTemplate(@Res() res: Response) {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('宿舍导入模板');
ws.columns = [
{ header: '房间号', key: 'roomNumber', width: 12 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '楼层', key: 'floor', width: 8 },
{ header: '额定人数', key: 'capacity', width: 10 },
{ header: '宿舍类型', key: 'roomType', width: 12 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ roomNumber: '4-102', building: '4号楼', floor: 1, capacity: 4, roomType: '四人间' });
ws.addRow({ roomNumber: '2-201', building: '2号楼', floor: 2, capacity: 1, roomType: '单人间' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename=room_template.xlsx');
await workbook.xlsx.write(res);
res.end();
}
@Get('export')
@RequirePermission('room:view')
async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response) {
const rooms = await this.service.getRoomOverview({ includeArchived: includeArchived === 'true' });
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('宿舍列表');
ws.columns = [
{ header: '房间号', key: 'roomNumber', width: 12 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '楼层', key: 'floor', width: 8 },
{ header: '宿舍类型', key: 'roomType', width: 12 },
{ header: '额定人数', key: 'capacity', width: 10 },
{ header: '当前入住', key: 'currentCount', width: 10 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '状态', key: 'status', width: 10 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { available: '可入住', full: '已满', maintenance: '维修中', archived: '已归档' };
for (const r of rooms) {
ws.addRow({ roomNumber: r.roomNumber, building: r.building || '', floor: r.floor || '', roomType: r.roomType || '', capacity: r.capacity, currentCount: r.currentCount, gender: r.gender || '', status: statusMap[r.status] || r.status });
}
res!.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res!.setHeader('Content-Disposition', 'attachment; filename=rooms.xlsx');
await workbook.xlsx.write(res!);
res!.end();
}
@Get(':id')
@RequirePermission('room:view')
findOne(@Param('id') id: string) {
return this.service.findOneWithOccupants(+id);
}
@Post()
@RequirePermission('room:create')
async create(@Body() dto: CreateRoomDto, @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: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}`, ipAddress, userAgent });
return result;
}
@Put(':id')
@RequirePermission('room:edit')
async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @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: 'room', detail: JSON.stringify(dto), ipAddress, userAgent });
return result;
}
@Delete(':id')
@RequirePermission('room: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: 'room', ipAddress, userAgent });
return result;
}
@Post('batch-delete')
@RequirePermission('room:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent });
return result;
}
@Put(':id/restore')
@RequirePermission('room: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: 'room', ipAddress, userAgent });
return result;
}
@Post('import')
@RequirePermission('room: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: { roomNumber: string; building?: string; floor?: number; capacity?: number; roomType?: string }[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
roomNumber: String(row.getCell(1).value || ''),
building: String(row.getCell(2).value || '') || undefined,
floor: Number(row.getCell(3).value) || undefined,
capacity: Number(row.getCell(4).value) || 4,
roomType: String(row.getCell(5).value || '').trim() || 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,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { RoomExpense } from '../entities/room-expense.entity';
import { RoomsService } from './rooms.service';
import { RoomsController } from './rooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense]), OperationLogsModule],
controllers: [RoomsController],
providers: [RoomsService],
exports: [RoomsService],
})
export class RoomsModule {}

View File

@@ -0,0 +1,224 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, IsNull, Not, In } from 'typeorm';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { RoomExpense } from '../entities/room-expense.entity';
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
@Injectable()
export class RoomsService {
constructor(
@InjectRepository(Room) private repo: Repository<Room>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
) {}
/**
* 智能解析房间号,自动推导楼栋、楼层、宿舍类型
* "4-102" → building:"4号楼", floor:1, roomType:"四人间"
* "1-2-101" → building:"1-2栋", floor:1, roomType:"家庭房"
* "3-301" → building:"3号楼", floor:3, roomType:"四人间"
* "8-102" → building:"8号楼", floor:1, roomType:"爆改房"
*/
static parseRoomNumber(roomNumber: string): { building?: string; floor?: number; roomType?: string; capacity?: number } {
const cleaned = roomNumber.replace(/[(].*?[)]/g, '').trim();
// 家庭房: X-Y-ZZZ 格式
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
if (familyMatch) {
const bldg = `${familyMatch[1]}-${familyMatch[2]}`;
const roomPart = familyMatch[3];
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
return { building: bldg, floor, roomType: '家庭房', capacity: 4 };
}
// 标准: X-YZZ 格式
const stdMatch = cleaned.match(/^(\d+)-(\d+)$/);
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const floor = roomPart.length >= 3 ? parseInt(roomPart.charAt(0), 10) : parseInt(roomPart.charAt(0), 10);
const building = `${bldgNum}号楼`;
let roomType = '四人间';
let capacity = 4;
if (bldgNum === '2') { roomType = '单人间'; capacity = 1; }
else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; }
return { building, floor, roomType, capacity };
}
return {};
}
async findAll(query?: { building?: string; includeArchived?: boolean }) {
const where: any = {};
if (query?.building) where.building = query.building;
if (!query?.includeArchived) where.status = Not('archived');
return this.repo.find({ where, order: { roomNumber: 'ASC' } });
}
async findOne(id: number) {
const room = await this.repo.findOne({ where: { id } });
if (!room) throw new NotFoundException('宿舍不存在');
return room;
}
async findOneWithOccupants(id: number) {
const room = await this.findOne(id);
const occupants = await this.occRepo.find({
where: { roomId: id, checkOutDate: IsNull() },
relations: ['student'],
order: { checkInDate: 'ASC' },
});
return { ...room, currentOccupants: occupants };
}
async getRoomOverview(query?: { includeArchived?: boolean }) {
const where: any = {};
if (!query?.includeArchived) where.status = Not('archived');
const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } });
const result: any[] = [];
for (const room of rooms) {
const count = await this.occRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
result.push({ ...room, currentCount: count });
}
return result;
}
async create(dto: CreateRoomDto) {
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateRoomDto) {
await this.findOne(id);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
const room = await this.findOne(id);
// 检查是否有在住人员
const activeCount = await this.occRepo.count({ where: { roomId: id, checkOutDate: IsNull() } });
if (activeCount > 0) throw new BadRequestException('该宿舍有在住人员,无法归档');
if (room.status === 'archived') throw new BadRequestException('该宿舍已归档');
// 软删除:归档而非物理删除
await this.repo.update(id, { status: 'archived' });
return { message: '已归档(数据已保留,可随时恢复)' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的宿舍');
const rooms = await this.repo.find({ where: { id: In(ids) } });
const skipped: string[] = [];
const targetIds: number[] = [];
for (const r of rooms) {
if (r.status === 'archived') {
skipped.push(`${r.roomNumber}(已归档)`);
continue;
}
const activeCount = await this.occRepo.count({ where: { roomId: r.id, checkOutDate: IsNull() } });
if (activeCount > 0) {
skipped.push(`${r.roomNumber}(有在住人员)`);
continue;
}
targetIds.push(r.id);
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message = skipped.length > 0
? `成功归档 ${affected} 间;${skipped.length} 间被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 间宿舍(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
}
async restore(id: number) {
const room = await this.findOne(id);
if (room.status !== 'archived') throw new BadRequestException('该宿舍未被归档');
await this.repo.update(id, { status: 'available' });
return { message: '已恢复' };
}
async getRoomVisual() {
const rooms = await this.repo.find({ where: { status: Not('archived') }, order: { building: 'ASC', roomNumber: 'ASC' } });
const occupancies = await this.occRepo.find({
where: { checkOutDate: IsNull() },
relations: ['student'],
order: { checkInDate: 'ASC' },
});
// 按roomId分组入住记录
const occMap = new Map<number, any[]>();
for (const occ of occupancies) {
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
const now = new Date();
const checkIn = new Date(occ.checkInDate);
const days = Math.max(1, Math.ceil((now.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
occMap.get(occ.roomId)!.push({
studentId: occ.studentId,
studentName: occ.student?.name || '未知',
checkInDate: occ.checkInDate,
billingStartDate: occ.billingStartDate,
days,
organization: occ.student?.organization || null,
supervisor: occ.student?.supervisor || null,
});
}
// 获取各楼栋列表
const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];
return {
buildings,
rooms: rooms.map((room) => {
const occ = occMap.get(room.id) || [];
// 计算机构标注
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
let orgLabel: string | null = null;
if (orgs.length > 0 && occ.length > 0) {
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
if (allSameOrg) {
orgLabel = `均为${orgs[0]}人员`;
} else {
orgLabel = `存在${orgs.join('、')}人员`;
}
}
return {
id: room.id,
roomNumber: room.roomNumber,
building: room.building,
floor: room.floor,
capacity: room.capacity,
status: room.status,
currentCount: occ.length,
occupants: occ,
orgLabel,
};
}),
};
}
async batchImport(rows: { roomNumber: string; building?: string; floor?: number; capacity?: number; roomType?: string }[]) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.roomNumber || !row.roomNumber.trim()) { skipped++; continue; }
const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (exists) { skipped++; continue; }
// 智能解析房间号
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
await this.repo.save(this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: row.floor || parsed.floor || undefined,
capacity: row.capacity || parsed.capacity || 4,
roomType: row.roomType || parsed.roomType || undefined,
}));
imported++;
}
return { message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, imported, skipped };
}
}