# 床位管理 & 柜子管理 — 实现计划 > **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:** 在宿舍管理模块中新增床位和柜子两个子资源,入住流程绑定床位,房间详情页升级为 Drawer + Tabs。 **Architecture:** 后端在 Rooms 模块下新增 Bed/Locker 实体与 CRUD,Occupancies 模块新增 bedId/lockerId 字段并在入住/退宿时联动床柜状态。前端 Rooms 页面重写详情为 Drawer + Tabs(基本信息/床位/柜子),Occupancies 页面改造入住登记表单(选房间→加载可用床位),RoomVisual 卡片底部加床位统计。 **Tech Stack:** NestJS 11 + TypeORM 0.3 + SQLite/MySQL, React 19 + Vite + Ant Design 6 ## Global Constraints - 敏感信息(手机号、身份证)需脱敏展示,查看时二次确认+记录日志 - 所有涉及大量数据的列表/表单页面必须有详细的筛选方案 - 遵循现有 NestJS 模块结构:每个业务模块独立目录,含 entity/dto/service/controller - 前端页面放在 `apps/admin/src/pages/` 下,每个模块独立目录 - React: 避免内联组件定义 (`rerender-no-inline-components`),静态 JSX 提取到组件外 (`rendering-hoist-jsx`),独立请求使用 Promise.all (`async-parallel`),直接导入避免 barrel files (`bundle-barrel-imports`) - UI: 表单使用可见标签(非 placeholder-only),输入框高度 ≥44px,异步操作显示 loading 状态,抽屉/弹窗有明确的关闭按钮 --- ## File Structure ``` # 后端 — 新增 apps/server/src/entities/bed.entity.ts # Bed 实体 apps/server/src/entities/locker.entity.ts # Locker 实体 apps/server/src/rooms/dto/bed.dto.ts # Bed DTO apps/server/src/rooms/dto/locker.dto.ts # Locker DTO # 后端 — 修改 apps/server/src/rooms/rooms.module.ts # 注册 Bed/Locker 仓库 apps/server/src/rooms/rooms.service.ts # 新增 bed/locker CRUD apps/server/src/rooms/rooms.controller.ts # 新增 bed/locker 路由 apps/server/src/rooms/dto/room.dto.ts # 无需改动(bed/locker 独立 DTO) apps/server/src/occupancies/occupancies.module.ts # 注册 Bed/Locker 仓库 apps/server/src/occupancies/occupancies.service.ts # 入住/退宿联动床柜状态 apps/server/src/occupancies/occupancies.controller.ts # 传递 bedId/lockerId apps/server/src/occupancies/dto/occupancy.dto.ts # CheckInDto 新增 bedId/lockerId apps/server/src/entities/occupancy.entity.ts # 新增 bedId/lockerId 字段 # 数据库 apps/server/migrations/... # 建表 + ALTER occupancies + 默认床位 # 前端 — 修改 apps/admin/src/pages/Rooms/index.tsx # Modal → Drawer + Tabs apps/admin/src/pages/Occupancies/index.tsx # 入住表单 + 床位/柜子列 apps/admin/src/pages/RoomVisual/index.tsx # 卡片底部床位统计 ``` --- ### Task 1: Bed & Locker 实体 **Files:** - Create: `apps/server/src/entities/bed.entity.ts` - Create: `apps/server/src/entities/locker.entity.ts` **Produces:** `Bed` entity, `Locker` entity — both with `room_id` FK to `rooms`, unique on `(room_id, number)`, status enum `available | occupied | maintenance`. - [ ] **Step 1: Create Bed entity** ```typescript // apps/server/src/entities/bed.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, } from 'typeorm'; import { Room } from './room.entity'; @Entity('beds') export class Bed { @PrimaryGeneratedColumn() id: number; @Column({ name: 'room_id' }) roomId: number; @ManyToOne(() => Room, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'room_id' }) room: Room; @Column({ name: 'bed_number', length: 20 }) bedNumber: string; @Column({ type: 'varchar', length: 20, default: 'available' }) status: string; @Column({ type: 'text', nullable: true }) notes: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` - [ ] **Step 2: Create Locker entity** ```typescript // apps/server/src/entities/locker.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, } from 'typeorm'; import { Room } from './room.entity'; @Entity('lockers') export class Locker { @PrimaryGeneratedColumn() id: number; @Column({ name: 'room_id' }) roomId: number; @ManyToOne(() => Room, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'room_id' }) room: Room; @Column({ name: 'locker_number', length: 20 }) lockerNumber: string; @Column({ type: 'varchar', length: 20, default: 'available' }) status: string; @Column({ type: 'text', nullable: true }) notes: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` - [ ] **Step 3: Commit** ```bash git add apps/server/src/entities/bed.entity.ts apps/server/src/entities/locker.entity.ts git commit -m "feat: add Bed and Locker entities with Room FK" ``` --- ### Task 2: 数据库迁移 & Occupancy 实体更新 **Files:** - Create: migration 脚本 - Modify: `apps/server/src/entities/occupancy.entity.ts` **Consumes:** `Bed`, `Locker` entities **Produces:** `beds` 表, `lockers` 表, `occupancies.bed_id` / `occupancies.locker_id` 列, 现有房间默认床位 - [ ] **Step 1: Update Occupancy entity** ```typescript // apps/server/src/entities/occupancy.entity.ts — 在现有 import 后新增: import { Bed } from './bed.entity'; import { Locker } from './locker.entity'; // 在 Occupancy 类中新增字段(加在 notes 字段后、createdAt 前): @Column({ name: 'bed_id', type: 'integer', nullable: true }) bedId: number; @ManyToOne(() => Bed, { nullable: true }) @JoinColumn({ name: 'bed_id' }) bed: Bed; @Column({ name: 'locker_id', type: 'integer', nullable: true }) lockerId: number; @ManyToOne(() => Locker, { nullable: true }) @JoinColumn({ name: 'locker_id' }) locker: Locker; ``` - [ ] **Step 2: 验证 TypeORM 配置支持 synchronize 或写迁移 SQL** 检查项目是否使用 `synchronize: true`: ```bash grep -r "synchronize" apps/server/src/ --include="*.ts" -l ``` 若 `true`:重启服务后表自动生成。 若 `false`:手动执行迁移 SQL。 - [ ] **Step 3: 手动迁移 SQL(如需要)** ```sql -- 创建 beds 表 CREATE TABLE IF NOT EXISTS beds ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL REFERENCES rooms(id), bed_number VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'available', notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(room_id, bed_number) ); -- 创建 lockers 表 CREATE TABLE IF NOT EXISTS lockers ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL REFERENCES rooms(id), locker_number VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'available', notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(room_id, locker_number) ); -- occupancies 新增字段 ALTER TABLE occupancies ADD COLUMN bed_id INTEGER REFERENCES beds(id); ALTER TABLE occupancies ADD COLUMN locker_id INTEGER REFERENCES lockers(id); -- 为现有房间生成默认床位(每个房间 capacity 张床) INSERT INTO beds (room_id, bed_number, status, created_at, updated_at) SELECT r.id, t.bed_label, 'available', datetime('now'), datetime('now') FROM rooms r CROSS JOIN ( SELECT 1 AS idx, '1号床' AS bed_label UNION ALL SELECT 2, '2号床' UNION ALL SELECT 3, '3号床' UNION ALL SELECT 4, '4号床' UNION ALL SELECT 5, '5号床' UNION ALL SELECT 6, '6号床' UNION ALL SELECT 7, '7号床' UNION ALL SELECT 8, '8号床' ) t WHERE t.idx <= r.capacity AND r.status != 'archived'; ``` - [ ] **Step 4: Commit** ```bash git add apps/server/src/entities/occupancy.entity.ts git commit -m "feat: add bed_id and locker_id to Occupancy entity" ``` --- ### Task 3: Bed & Locker DTOs **Files:** - Create: `apps/server/src/rooms/dto/bed.dto.ts` - Create: `apps/server/src/rooms/dto/locker.dto.ts` **Produces:** `CreateBedDto`, `UpdateBedDto`, `BatchCreateBedDto`, `CreateLockerDto`, `UpdateLockerDto`, `BatchCreateLockerDto` - [ ] **Step 1: Create Bed DTO** ```typescript // apps/server/src/rooms/dto/bed.dto.ts import { IsString, IsOptional, IsInt, Min, IsEnum, IsArray, ArrayMinSize } from 'class-validator'; export class CreateBedDto { @IsString() bedNumber: string; @IsOptional() @IsString() status?: string; @IsOptional() @IsString() notes?: string; } export class UpdateBedDto { @IsOptional() @IsString() bedNumber?: string; @IsOptional() @IsEnum(['available', 'occupied', 'maintenance']) status?: string; @IsOptional() @IsString() notes?: string; } export class BatchCreateBedDto { @IsInt() @Min(1) count: number; } ``` - [ ] **Step 2: Create Locker DTO** ```typescript // apps/server/src/rooms/dto/locker.dto.ts import { IsString, IsOptional, IsInt, Min, IsEnum } from 'class-validator'; export class CreateLockerDto { @IsString() lockerNumber: string; @IsOptional() @IsString() status?: string; @IsOptional() @IsString() notes?: string; } export class UpdateLockerDto { @IsOptional() @IsString() lockerNumber?: string; @IsOptional() @IsEnum(['available', 'occupied', 'maintenance']) status?: string; @IsOptional() @IsString() notes?: string; } export class BatchCreateLockerDto { @IsInt() @Min(1) count: number; } ``` - [ ] **Step 3: Commit** ```bash git add apps/server/src/rooms/dto/bed.dto.ts apps/server/src/rooms/dto/locker.dto.ts git commit -m "feat: add Bed and Locker DTOs" ``` --- ### Task 4: Bed & Locker CRUD in RoomsService **Files:** - Modify: `apps/server/src/rooms/rooms.service.ts` - Modify: `apps/server/src/rooms/rooms.module.ts` **Consumes:** `Bed`, `Locker` entities, DTOs **Produces:** `RoomsService` 新增 bed/locker CRUD 方法 - [ ] **Step 1: 更新 RoomsModule** ```typescript // apps/server/src/rooms/rooms.module.ts import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; // TypeOrmModule.forFeature 从 [Room, Occupancy, RoomExpense] 改为: [Room, Occupancy, RoomExpense, Bed, Locker] ``` - [ ] **Step 2: 在 RoomsService 构造函数中注入 repo** ```typescript // apps/server/src/rooms/rooms.service.ts — import 新增: import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; // constructor 新增: @InjectRepository(Bed) private bedRepo: Repository, @InjectRepository(Locker) private lockerRepo: Repository, ``` - [ ] **Step 3: 新增床位 CRUD 方法** ```typescript // rooms.service.ts — 在现有方法后追加: // ── 床位管理 ── async getRoomBeds(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } }); } async getRoomAvailableBeds(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.bedRepo.find({ where: { roomId, status: 'available' }, order: { bedNumber: 'ASC' } }); } async createBed(roomId: number, dto: CreateBedDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); if (existing) throw new BadRequestException('该床位编号已存在'); const bed = this.bedRepo.create({ ...dto, roomId }); return this.bedRepo.save(bed); } async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { const bed = await this.bedRepo.findOne({ where: { id, roomId } }); if (!bed) throw new NotFoundException('床位不存在'); // 不允许将 occupied 的床位改为 maintenance if (dto.status === 'maintenance' && bed.status === 'occupied') { throw new BadRequestException('该床位有人入住,请先退宿'); } // 编号唯一性检查 if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) { const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); if (dup) throw new BadRequestException('该床位编号已存在'); } Object.assign(bed, dto); return this.bedRepo.save(bed); } async deleteBed(roomId: number, id: number): Promise { const bed = await this.bedRepo.findOne({ where: { id, roomId } }); if (!bed) throw new NotFoundException('床位不存在'); if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法删除'); await this.bedRepo.remove(bed); } async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); const existing = await this.bedRepo.count({ where: { roomId } }); const start = existing + 1; const beds: Bed[] = []; for (let i = 0; i < dto.count; i++) { beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` })); } return this.bedRepo.save(beds); } ``` - [ ] **Step 4: 新增柜子 CRUD 方法** ```typescript // rooms.service.ts — 在床位方法后追加: // ── 柜子管理 ── async getRoomLockers(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } }); } async getRoomAvailableLockers(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.lockerRepo.find({ where: { roomId, status: 'available' }, order: { lockerNumber: 'ASC' } }); } async createLocker(roomId: number, dto: CreateLockerDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); const existing = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } }); if (existing) throw new BadRequestException('该柜子编号已存在'); const locker = this.lockerRepo.create({ ...dto, roomId }); return this.lockerRepo.save(locker); } async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise { const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); if (!locker) throw new NotFoundException('柜子不存在'); if (dto.status === 'maintenance' && locker.status === 'occupied') { throw new BadRequestException('该柜子有人占用,请先释放'); } if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) { const dup = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } }); if (dup) throw new BadRequestException('该柜子编号已存在'); } Object.assign(locker, dto); return this.lockerRepo.save(locker); } async deleteLocker(roomId: number, id: number): Promise { const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); if (!locker) throw new NotFoundException('柜子不存在'); if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法删除'); await this.lockerRepo.remove(locker); } async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); const existing = await this.lockerRepo.count({ where: { roomId } }); const start = existing + 1; const lockers: Locker[] = []; for (let i = 0; i < dto.count; i++) { lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` })); } return this.lockerRepo.save(lockers); } ``` - [ ] **Step 5: Commit** ```bash git add apps/server/src/rooms/rooms.module.ts apps/server/src/rooms/rooms.service.ts git commit -m "feat: add Bed and Locker CRUD to RoomsService" ``` --- ### Task 5: Bed & Locker Routes in RoomsController **Files:** - Modify: `apps/server/src/rooms/rooms.controller.ts` **Consumes:** `RoomsService` bed/locker methods **Produces:** `/rooms/:roomId/beds/*` 和 `/rooms/:roomId/lockers/*` 路由 - [ ] **Step 1: 在 RoomsController 中新增床位路由** 在 `rooms.controller.ts` 的 `export class RoomsController` 中,现有 `findOne(@Param('id') id: string)` 之后、`@Post()` 之前插入: ```typescript // ── 床位管理 ── @Get(':roomId/beds') @RequirePermission('room:view') getBeds(@Param('roomId') roomId: string) { return this.service.getRoomBeds(+roomId); } @Get(':roomId/beds/available') @RequirePermission('room:view') getAvailableBeds(@Param('roomId') roomId: string) { return this.service.getRoomAvailableBeds(+roomId); } @Post(':roomId/beds') @RequirePermission('room:edit') createBed(@Param('roomId') roomId: string, @Body() dto: CreateBedDto) { return this.service.createBed(+roomId, dto); } @Put(':roomId/beds/:id') @RequirePermission('room:edit') updateBed( @Param('roomId') roomId: string, @Param('id') id: string, @Body() dto: UpdateBedDto, ) { return this.service.updateBed(+roomId, +id, dto); } @Delete(':roomId/beds/:id') @RequirePermission('room:edit') deleteBed(@Param('roomId') roomId: string, @Param('id') id: string) { return this.service.deleteBed(+roomId, +id); } @Post(':roomId/beds/batch') @RequirePermission('room:edit') batchCreateBeds(@Param('roomId') roomId: string, @Body() dto: BatchCreateBedDto) { return this.service.batchCreateBeds(+roomId, dto); } // ── 柜子管理 ── @Get(':roomId/lockers') @RequirePermission('room:view') getLockers(@Param('roomId') roomId: string) { return this.service.getRoomLockers(+roomId); } @Get(':roomId/lockers/available') @RequirePermission('room:view') getAvailableLockers(@Param('roomId') roomId: string) { return this.service.getRoomAvailableLockers(+roomId); } @Post(':roomId/lockers') @RequirePermission('room:edit') createLocker(@Param('roomId') roomId: string, @Body() dto: CreateLockerDto) { return this.service.createLocker(+roomId, dto); } @Put(':roomId/lockers/:id') @RequirePermission('room:edit') updateLocker( @Param('roomId') roomId: string, @Param('id') id: string, @Body() dto: UpdateLockerDto, ) { return this.service.updateLocker(+roomId, +id, dto); } @Delete(':roomId/lockers/:id') @RequirePermission('room:edit') deleteLocker(@Param('roomId') roomId: string, @Param('id') id: string) { return this.service.deleteLocker(+roomId, +id); } @Post(':roomId/lockers/batch') @RequirePermission('room:edit') batchCreateLockers(@Param('roomId') roomId: string, @Body() dto: BatchCreateLockerDto) { return this.service.batchCreateLockers(+roomId, dto); } ``` **注意**:这些路由使用 `:roomId` 而非 `:id`,路径为 `/rooms/:roomId/beds`。需要确保 `:roomId` 路由在 `:id` 路由之前注册(NestJS 按声明顺序匹配),否则 `/rooms/1/beds` 会被 `:id` 捕获并调用 `findOne`。当前 `@Get(':id')` 在第 147 行,所以新路由必须在它前面。 - [ ] **Step 2: 更新 import** 在 controller 文件顶部 import 语句中新增 DTO imports: ```typescript import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; ``` - [ ] **Step 3: 验证路由顺序 — 重新阅读文件确保正确位置** 读取 `rooms.controller.ts` 确认上述代码插入在 `@Get(':id')` 之前。 - [ ] **Step 4: Commit** ```bash git add apps/server/src/rooms/rooms.controller.ts git commit -m "feat: add Bed and Locker REST routes to RoomsController" ``` --- ### Task 6: Occupancies 模块更新 **Files:** - Modify: `apps/server/src/occupancies/dto/occupancy.dto.ts` - Modify: `apps/server/src/occupancies/occupancies.module.ts` - Modify: `apps/server/src/occupancies/occupancies.service.ts` - Modify: `apps/server/src/occupancies/occupancies.controller.ts` **Consumes:** `Bed`, `Locker` entities, bed/locker CRUD **Produces:** 入住必选床位,退宿/换房联动床柜状态 - [ ] **Step 1: 更新 CheckInDto 和 TransferRoomDto** ```typescript // occupancy.dto.ts — CheckInDto 新增: @IsInt() @IsOptional() // 历史兼容暂时可选,前端强制必填 bedId?: number; // 后续改 required @IsOptional() @IsInt() lockerId?: number; // TransferRoomDto 新增: @IsInt() @IsOptional() newBedId?: number; @IsOptional() @IsInt() newLockerId?: number; ``` - [ ] **Step 2: 更新 OccupanciesModule** ```typescript // occupancies.module.ts import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; // TypeOrmModule.forFeature 从 [Occupancy, Room, Student, Deposit] 改为: [Occupancy, Room, Student, Deposit, Bed, Locker] ``` - [ ] **Step 3: 更新 OccupanciesService** ```typescript // occupancies.service.ts — 新增 import: import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; // constructor 新增: @InjectRepository(Bed) private bedRepo: Repository, @InjectRepository(Locker) private lockerRepo: Repository, ``` **修改 `checkIn` 方法** — 在容量检查之后、创建记录之前,新增床位校验: ```typescript // 在 "检查宿舍容量" 代码块之后插入: // 床位校验 if (dto.bedId) { const bed = await this.bedRepo.findOne({ where: { id: dto.bedId, roomId: dto.roomId } }); if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍'); if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中'); } // 柜子校验 if (dto.lockerId) { const locker = await this.lockerRepo.findOne({ where: { id: dto.lockerId, roomId: dto.roomId } }); if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍'); if (locker.status !== 'available') throw new BadRequestException('该柜子已被占用或维修中'); } ``` **修改 occ 创建** — 在 `const occ = this.repo.create({` 中新增: ```typescript bedId: dto.bedId, lockerId: dto.lockerId, ``` **在 `const saved = await this.repo.save(occ)` 之后插入床柜状态更新:** ```typescript // 更新床位/柜子状态 if (dto.bedId) { await this.bedRepo.update(dto.bedId, { status: 'occupied' }); } if (dto.lockerId) { await this.lockerRepo.update(dto.lockerId, { status: 'occupied' }); } ``` **修改 `checkOut` 方法** — 在 `await this.repo.save(occ)` 之后插入: ```typescript // 释放床位/柜子 if (occ.bedId) { await this.bedRepo.update(occ.bedId, { status: 'available' }); } if (occ.lockerId) { await this.lockerRepo.update(occ.lockerId, { status: 'available' }); } ``` **修改 `transferRoom` 方法** — 在事务中: 退旧房时(在 `await runner.manager.save(oldOcc)` 之后): ```typescript // 释放旧床位/柜子 if (oldOcc.bedId) { await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' }); } if (oldOcc.lockerId) { await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); } ``` 入住新房时 — 床位校验(在容量检查之后): ```typescript // 新床位校验 if (dto.newBedId) { const newBed = await runner.manager.findOne(Bed, { where: { id: dto.newBedId, roomId: dto.newRoomId } }); if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); } if (dto.newLockerId) { const newLocker = await runner.manager.findOne(Locker, { where: { id: dto.newLockerId, roomId: dto.newRoomId } }); if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); } ``` 创建新房记录时新增: ```typescript bedId: dto.newBedId, lockerId: dto.newLockerId, ``` 保存新房记录后新增: ```typescript if (dto.newBedId) { await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' }); } if (dto.newLockerId) { await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' }); } ``` **更新 `findAll` 方法** — 在返回数据时关联 bed/locker 信息: ```typescript // 方法签名保持不变,内部查询添加 relations return this.repo.find({ where, relations: ['student', 'room', 'bed', 'locker', 'tenant'], order: { checkInDate: 'DESC' }, }); ``` - [ ] **Step 4: 验证服务端编译** ```bash cd apps/server && npx nest build 2>&1 | tail -20 ``` Expect: 无编译错误。 - [ ] **Step 5: Commit** ```bash git add apps/server/src/occupancies/ git commit -m "feat: integrate Bed/Locker into Occupancy check-in/out flow" ``` --- ### Task 7: Rooms 页面 — Modal 升级为 Drawer + Tabs **Files:** - Modify: `apps/admin/src/pages/Rooms/index.tsx` **Consumes:** bed/locker API routes **Produces:** 房间详情 Drawer(基本信息 / 床位管理 / 柜子管理三 Tab) - [ ] **Step 1: 新增 import** ```typescript // Rooms/index.tsx — 在现有 import 后追加: import { Drawer, Tabs, Badge, Tooltip } from 'antd'; // Tabs 和 Badge 应该已存在,检查是否需要新增 Drawer ``` - [ ] **Step 2: 新增状态变量** 在组件顶部 state 声明区(第 64-77 行附近)新增: ```typescript const [drawerOpen, setDrawerOpen] = useState(false); const [drawerRoom, setDrawerRoom] = useState(null); const [beds, setBeds] = useState([]); const [lockers, setLockers] = useState([]); const [bedModalOpen, setBedModalOpen] = useState(false); const [bedEditing, setBedEditing] = useState(null); const [lockerModalOpen, setLockerModalOpen] = useState(false); const [lockerEditing, setLockerEditing] = useState(null); const [bedForm] = Form.useForm(); const [lockerForm] = Form.useForm(); const [savingBed, setSavingBed] = useState(false); const [savingLocker, setSavingLocker] = useState(false); ``` - [ ] **Step 3: 新增床位/柜子数据加载** ```typescript const fetchBeds = async (roomId: number) => { try { const res = await api.get(`/rooms/${roomId}/beds`); setBeds(res); } catch (e) { console.error(e); } }; const fetchLockers = async (roomId: number) => { try { const res = await api.get(`/rooms/${roomId}/lockers`); setLockers(res); } catch (e) { console.error(e); } }; ``` - [ ] **Step 4: 新增床位 CRUD 操作函数** ```typescript const handleSaveBed = async () => { const values = await bedForm.validateFields(); setSavingBed(true); try { if (bedEditing) { await api.put(`/rooms/${drawerRoom.id}/beds/${bedEditing.id}`, values); } else { await api.post(`/rooms/${drawerRoom.id}/beds`, values); } message.success(bedEditing ? '更新成功' : '添加成功'); setBedModalOpen(false); bedForm.resetFields(); setBedEditing(null); fetchBeds(drawerRoom.id); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSavingBed(false); } }; const handleDeleteBed = async (id: number) => { try { await api.delete(`/rooms/${drawerRoom.id}/beds/${id}`); message.success('已删除'); fetchBeds(drawerRoom.id); } catch (e: any) { message.error(e?.message || '删除失败'); } }; const handleBatchBeds = async (count: number) => { try { await api.post(`/rooms/${drawerRoom.id}/beds/batch`, { count }); message.success(`已生成 ${count} 张床位`); fetchBeds(drawerRoom.id); } catch (e: any) { message.error(e?.message || '批量生成失败'); } }; ``` - [ ] **Step 5: 新增柜子 CRUD 操作函数(结构同上,替换 bed → locker)** ```typescript const handleSaveLocker = async () => { const values = await lockerForm.validateFields(); setSavingLocker(true); try { if (lockerEditing) { await api.put(`/rooms/${drawerRoom.id}/lockers/${lockerEditing.id}`, values); } else { await api.post(`/rooms/${drawerRoom.id}/lockers`, values); } message.success(lockerEditing ? '更新成功' : '添加成功'); setLockerModalOpen(false); lockerForm.resetFields(); setLockerEditing(null); fetchLockers(drawerRoom.id); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSavingLocker(false); } }; const handleDeleteLocker = async (id: number) => { try { await api.delete(`/rooms/${drawerRoom.id}/lockers/${id}`); message.success('已删除'); fetchLockers(drawerRoom.id); } catch (e: any) { message.error(e?.message || '删除失败'); } }; const handleBatchLockers = async (count: number) => { try { await api.post(`/rooms/${drawerRoom.id}/lockers/batch`, { count }); message.success(`已生成 ${count} 个柜子`); fetchLockers(drawerRoom.id); } catch (e: any) { message.error(e?.message || '批量生成失败'); } }; ``` - [ ] **Step 6: 替换详情 Modal 为 Drawer** 删除现有的"查看住户"Modal(第 481-502 行),替换为: ```typescript { setDrawerOpen(false); setDrawerRoom(null); }} width={640} destroyOnClose >
房间号:{drawerRoom.roomNumber}
楼栋:{drawerRoom.building || '-'}
楼层:{drawerRoom.floor ?? '-'}
类型:{drawerRoom.roomType || '-'}
额定人数:{drawerRoom.capacity}
租赁类别:{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
月租金:{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
状态:{statusMap[drawerRoom.status]?.text}
), }, { key: 'beds', label: `床位管理 (${beds.length})`, children: (
} onConfirm={() => { const input = document.getElementById('batch-bed-count') as HTMLInputElement; handleBatchBeds(input ? parseInt(input.value) || 4 : 4); }} okText="生成" disabled={drawerRoom?.status === 'archived'} >
{ const map: Record = { available: { text: '空闲', color: 'green' }, occupied: { text: '占用', color: 'blue' }, maintenance: { text: '维修', color: 'orange' }, }; return {map[s]?.text || s}; }, }, { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, { title: '操作', width: 120, render: (_: any, r: any) => ( { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }} > 编辑 {r.status !== 'occupied' && ( handleDeleteBed(r.id)}> 删除 )} ), }, ]} /> ), }, { key: 'lockers', label: `柜子管理 (${lockers.length})`, children: (
} onConfirm={() => { const input = document.getElementById('batch-locker-count') as HTMLInputElement; handleBatchLockers(input ? parseInt(input.value) || 4 : 4); }} okText="生成" disabled={drawerRoom?.status === 'archived'} >
{ const map: Record = { available: { text: '空闲', color: 'green' }, occupied: { text: '占用', color: 'blue' }, maintenance: { text: '维修', color: 'orange' }, }; return {map[s]?.text || s}; }, }, { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, { title: '操作', width: 120, render: (_: any, r: any) => ( { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }} > 编辑 {r.status !== 'occupied' && ( handleDeleteLocker(r.id)}> 删除 )} ), }, ]} /> ), }, ]} /> ``` - [ ] **Step 7: 修改"查看住户"按钮行为** 将 `showDetail(record.id)` 的 onClick 改为: ```typescript onClick={async () => { setDrawerRoom(record); setDrawerOpen(true); // 异步加载床位和柜子 await Promise.all([fetchBeds(record.id), fetchLockers(record.id)]); }} ``` - [ ] **Step 8: 新增床位/柜子编辑 Modal** 在 Drawer 之外(放在现有"添加宿舍"Modal 之后、最终 `` 之前),新增两个小型 Modal: ```typescript {/* 床位编辑弹窗 */} { setBedModalOpen(false); setBedEditing(null); }} confirmLoading={savingBed} okText="保存" >
({ value: b.id, label: b.bedNumber, }))} notFoundContent="该房间暂无可用床位" /> {availableBeds.length > 0 && (
空闲 {availableBeds.length} 张床位
)} ({ value: r.id, label: r.roomNumber, disabled: r.status === 'maintenance' || r.status === 'archived', }))} /> ``` - [ ] **Step 5: 入住提交时传 bedId/lockerId** 确认 `handleCheckIn` 中 payload 包含: ```typescript bedId: values.bedId, lockerId: values.lockerId || undefined, ``` - [ ] **Step 6: 表格新增床位号/柜子号列** 在表格 columns 中,`宿舍` 列之后新增: ```typescript { title: '床位', width: 80, render: (_: any, r: any) => r.bed?.bedNumber || '-', }, { title: '柜子', width: 80, render: (_: any, r: any) => r.locker?.lockerNumber || '-', }, ``` - [ ] **Step 7: Commit** ```bash git add apps/admin/src/pages/Occupancies/index.tsx git commit -m "feat: add bed/locker selection to check-in form and occupancy table" ``` --- ### Task 9: RoomVisual 卡片 — 床位统计 **Files:** - Modify: `apps/admin/src/pages/RoomVisual/index.tsx` **Consumes:** bed counts (可从现有 data 中扩展,或新增 API) **Produces:** 卡片底部显示「🛏 2/4 床」 - [ ] **Step 1: 确认 API 返回 bed 数据** 检查 `/rooms/visual` 返回结构是否包含床位统计。若不包含,先修改 `getRoomVisual` 方法在 `rooms.service.ts` 中添加 bed 统计: ```typescript // 在 getRoomVisual 方法中,为每个 room 计算 bed 统计: const totalBeds = await this.bedRepo.count({ where: { roomId: room.id } }); const occupiedBeds = await this.bedRepo.count({ where: { roomId: room.id, status: 'occupied' } }); // 添加字段:totalBeds, occupiedBeds ``` - [ ] **Step 2: 在 RoomVisual 卡片底部新增床位统计** 在卡片 JSX 中,`getTenantTags` 之后、`getStatusLabel` 之前的位置,新增: ```typescript {/* 床位统计 */} {room.totalBeds > 0 && (
= room.totalBeds ? '#ff4d4f' : '#52c41a', marginBottom: 4 }}> 床位: {room.occupiedBeds}/{room.totalBeds}
)} ``` - [ ] **Step 3: 修改统计栏** 将现有的 `availableBeds`(基于 capacity - currentCount)替换为基于实际 beds 统计: ```typescript const totalBeds = rooms.reduce((sum: number, r: any) => sum + (r.totalBeds || 0), 0); const occupiedBeds = rooms.reduce((sum: number, r: any) => sum + (r.occupiedBeds || 0), 0); const availableBedsCount = totalBeds - occupiedBeds; ``` 并在 Statistic 卡片中使用真实统计值。 - [ ] **Step 4: Commit** ```bash git add apps/admin/src/pages/RoomVisual/index.tsx apps/server/src/rooms/rooms.service.ts git commit -m "feat: add bed occupancy stats to RoomVisual cards" ``` --- ### Task 10: 端到端验证 & 清理 **Files:** 无新建,仅验证 **Consumes:** 所有前序任务 **Produces:** 验证通过的完整功能 - [ ] **Step 1: 启动后端** ```bash cd apps/server && npm run start:dev & ``` Wait for: Nest application successfully started. - [ ] **Step 2: 测试床位 API** ```bash # 获取某房间的床位 curl -s http://localhost:3000/api/rooms/1/beds | head -c 200 # Expect: JSON 数组,包含 bedNumber, status 字段 ``` - [ ] **Step 3: 测试入住 API(带床位)** ```bash curl -s -X POST http://localhost:3000/api/occupancies/check-in \ -H "Content-Type: application/json" \ -d '{"studentId":1,"roomId":1,"checkInDate":"2026-07-09","bedId":1}' | head -c 200 # Expect: 返回入住记录,包含 bedId ``` - [ ] **Step 4: 启动前端验证** ```bash cd apps/admin && npm run dev ``` 打开浏览器,验证: 1. 宿舍管理 → 点击"查看住户" → 弹出 Drawer → 三个 Tab 正常切换 2. 床位管理 Tab:可添加/编辑/删除/批量生成床位 3. 柜子管理 Tab:同样 CRUD 操作正常 4. 入住管理 → 入住登记 → 选房间后自动加载可用床位下拉 5. 入住后床位状态变为"占用" 6. 退宿后床位状态恢复"空闲" 7. 宿舍总览卡片显示床位统计 - [ ] **Step 5: 修复发现的问题并提交** ```bash git add -A git commit -m "chore: E2E verification fixes for bed/locker management" ``` --- ## Self-Review - [x] **Spec coverage**: All 8 sections covered — data model (Task 1-2), backend API (Task 3-5), occupancy integration (Task 6), frontend Drawer (Task 7), check-in form (Task 8), RoomVisual (Task 9), migration (Task 2) - [x] **Placeholder scan**: No TBD/TODO. All code blocks are concrete. - [x] **Type consistency**: `Bed`, `Locker` entity names match across tasks. `bedId`/`lockerId` field names consistent. `CreateBedDto`/`UpdateBedDto` used in both service and controller. - [x] **Route ordering**: Called out the critical `:roomId` vs `:id` route ordering issue in Task 5.