diff --git a/docs/superpowers/plans/2026-07-09-bed-locker-management.md b/docs/superpowers/plans/2026-07-09-bed-locker-management.md new file mode 100644 index 0000000..7b0e31c --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-bed-locker-management.md @@ -0,0 +1,1494 @@ +# 床位管理 & 柜子管理 — 实现计划 + +> **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.