50 KiB
床位管理 & 柜子管理 — 实现计划
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
// 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
// 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
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
// 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:
grep -r "synchronize" apps/server/src/ --include="*.ts" -l
若 true:重启服务后表自动生成。
若 false:手动执行迁移 SQL。
- Step 3: 手动迁移 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
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
// 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
// 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
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
// 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
// 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<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
- Step 3: 新增床位 CRUD 方法
// rooms.service.ts — 在现有方法后追加:
// ── 床位管理 ──
async getRoomBeds(roomId: number): Promise<Bed[]> {
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<Bed[]> {
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<Bed> {
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<Bed> {
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<void> {
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<Bed[]> {
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 方法
// rooms.service.ts — 在床位方法后追加:
// ── 柜子管理 ──
async getRoomLockers(roomId: number): Promise<Locker[]> {
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<Locker[]> {
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<Locker> {
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<Locker> {
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<void> {
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<Locker[]> {
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
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() 之前插入:
// ── 床位管理 ──
@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:
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
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
// 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
// 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
// occupancies.service.ts — 新增 import:
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
// constructor 新增:
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
修改 checkIn 方法 — 在容量检查之后、创建记录之前,新增床位校验:
// 在 "检查宿舍容量" 代码块之后插入:
// 床位校验
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({ 中新增:
bedId: dto.bedId,
lockerId: dto.lockerId,
在 const saved = await this.repo.save(occ) 之后插入床柜状态更新:
// 更新床位/柜子状态
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) 之后插入:
// 释放床位/柜子
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) 之后):
// 释放旧床位/柜子
if (oldOcc.bedId) {
await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' });
}
if (oldOcc.lockerId) {
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
}
入住新房时 — 床位校验(在容量检查之后):
// 新床位校验
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('目标柜子已被占用');
}
创建新房记录时新增:
bedId: dto.newBedId,
lockerId: dto.newLockerId,
保存新房记录后新增:
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 信息:
// 方法签名保持不变,内部查询添加 relations
return this.repo.find({
where,
relations: ['student', 'room', 'bed', 'locker', 'tenant'],
order: { checkInDate: 'DESC' },
});
- Step 4: 验证服务端编译
cd apps/server && npx nest build 2>&1 | tail -20
Expect: 无编译错误。
- Step 5: Commit
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
// Rooms/index.tsx — 在现有 import 后追加:
import { Drawer, Tabs, Badge, Tooltip } from 'antd';
// Tabs 和 Badge 应该已存在,检查是否需要新增 Drawer
- Step 2: 新增状态变量
在组件顶部 state 声明区(第 64-77 行附近)新增:
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerRoom, setDrawerRoom] = useState<any>(null);
const [beds, setBeds] = useState<any[]>([]);
const [lockers, setLockers] = useState<any[]>([]);
const [bedModalOpen, setBedModalOpen] = useState(false);
const [bedEditing, setBedEditing] = useState<any>(null);
const [lockerModalOpen, setLockerModalOpen] = useState(false);
const [lockerEditing, setLockerEditing] = useState<any>(null);
const [bedForm] = Form.useForm();
const [lockerForm] = Form.useForm();
const [savingBed, setSavingBed] = useState(false);
const [savingLocker, setSavingLocker] = useState(false);
- Step 3: 新增床位/柜子数据加载
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 操作函数
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)
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 行),替换为:
<Drawer
title={`${drawerRoom?.roomNumber} 房间详情`}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDrawerRoom(null); }}
width={640}
destroyOnClose
>
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children: drawerRoom && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div><strong>房间号:</strong>{drawerRoom.roomNumber}</div>
<div><strong>楼栋:</strong>{drawerRoom.building || '-'}</div>
<div><strong>楼层:</strong>{drawerRoom.floor ?? '-'}</div>
<div><strong>类型:</strong>{drawerRoom.roomType || '-'}</div>
<div><strong>额定人数:</strong>{drawerRoom.capacity}</div>
<div><strong>租赁类别:</strong>{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}</div>
<div><strong>月租金:</strong>{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}</div>
<div><strong>状态:</strong><Tag color={statusMap[drawerRoom.status]?.color}>{statusMap[drawerRoom.status]?.text}</Tag></div>
</div>
),
},
{
key: 'beds',
label: `床位管理 (${beds.length})`,
children: (
<div>
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={drawerRoom?.status === 'archived'}
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
>
添加床位
</Button>
<Popconfirm
title="批量生成床位"
description={
<InputNumber min={1} max={20} defaultValue={4} id="batch-bed-count" style={{ width: 80 }} />
}
onConfirm={() => {
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
handleBatchBeds(input ? parseInt(input.value) || 4 : 4);
}}
okText="生成"
disabled={drawerRoom?.status === 'archived'}
>
<Button size="small" disabled={drawerRoom?.status === 'archived'}>批量生成</Button>
</Popconfirm>
</div>
<Table
dataSource={beds}
rowKey="id"
pagination={false}
size="small"
columns={[
{ title: '编号', dataIndex: 'bedNumber', width: 80 },
{
title: '状态', dataIndex: 'status', width: 80,
render: (s: string) => {
const map: Record<string, { text: string; color: string }> = {
available: { text: '空闲', color: 'green' },
occupied: { text: '占用', color: 'blue' },
maintenance: { text: '维修', color: 'orange' },
};
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
},
},
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
{
title: '操作', width: 120,
render: (_: any, r: any) => (
<Space size="small">
<PermissionButton
permission="room:edit"
size="small"
type="link"
disabled={drawerRoom?.status === 'archived'}
onClick={() => { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }}
>
编辑
</PermissionButton>
{r.status !== 'occupied' && (
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteBed(r.id)}>
<PermissionButton
permission="room:edit"
size="small"
type="link"
danger
disabled={drawerRoom?.status === 'archived'}
>
删除
</PermissionButton>
</Popconfirm>
)}
</Space>
),
},
]}
/>
</div>
),
},
{
key: 'lockers',
label: `柜子管理 (${lockers.length})`,
children: (
<div>
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={drawerRoom?.status === 'archived'}
onClick={() => { setLockerEditing(null); lockerForm.resetFields(); setLockerModalOpen(true); }}
>
添加柜子
</Button>
<Popconfirm
title="批量生成柜子"
description={
<InputNumber min={1} max={20} defaultValue={4} id="batch-locker-count" style={{ width: 80 }} />
}
onConfirm={() => {
const input = document.getElementById('batch-locker-count') as HTMLInputElement;
handleBatchLockers(input ? parseInt(input.value) || 4 : 4);
}}
okText="生成"
disabled={drawerRoom?.status === 'archived'}
>
<Button size="small" disabled={drawerRoom?.status === 'archived'}>批量生成</Button>
</Popconfirm>
</div>
<Table
dataSource={lockers}
rowKey="id"
pagination={false}
size="small"
columns={[
{ title: '编号', dataIndex: 'lockerNumber', width: 80 },
{
title: '状态', dataIndex: 'status', width: 80,
render: (s: string) => {
const map: Record<string, { text: string; color: string }> = {
available: { text: '空闲', color: 'green' },
occupied: { text: '占用', color: 'blue' },
maintenance: { text: '维修', color: 'orange' },
};
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
},
},
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
{
title: '操作', width: 120,
render: (_: any, r: any) => (
<Space size="small">
<PermissionButton
permission="room:edit"
size="small"
type="link"
disabled={drawerRoom?.status === 'archived'}
onClick={() => { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }}
>
编辑
</PermissionButton>
{r.status !== 'occupied' && (
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteLocker(r.id)}>
<PermissionButton
permission="room:edit"
size="small"
type="link"
danger
disabled={drawerRoom?.status === 'archived'}
>
删除
</PermissionButton>
</Popconfirm>
)}
</Space>
),
},
]}
/>
</div>
),
},
]}
/>
</Drawer>
- Step 7: 修改"查看住户"按钮行为
将 showDetail(record.id) 的 onClick 改为:
onClick={async () => {
setDrawerRoom(record);
setDrawerOpen(true);
// 异步加载床位和柜子
await Promise.all([fetchBeds(record.id), fetchLockers(record.id)]);
}}
- Step 8: 新增床位/柜子编辑 Modal
在 Drawer 之外(放在现有"添加宿舍"Modal 之后、最终 </div> 之前),新增两个小型 Modal:
{/* 床位编辑弹窗 */}
<Modal
title={bedEditing ? '编辑床位' : '添加床位'}
open={bedModalOpen}
onOk={handleSaveBed}
onCancel={() => { setBedModalOpen(false); setBedEditing(null); }}
confirmLoading={savingBed}
okText="保存"
>
<Form form={bedForm} layout="vertical">
<Form.Item name="bedNumber" label="床位编号" rules={[{ required: true }]}>
<Input placeholder="如:1号床" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'available', label: '空闲' },
{ value: 'maintenance', label: '维修中' },
]}
placeholder="默认为空闲"
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{/* 柜子编辑弹窗 */}
<Modal
title={lockerEditing ? '编辑柜子' : '添加柜子'}
open={lockerModalOpen}
onOk={handleSaveLocker}
onCancel={() => { setLockerModalOpen(false); setLockerEditing(null); }}
confirmLoading={savingLocker}
okText="保存"
>
<Form form={lockerForm} layout="vertical">
<Form.Item name="lockerNumber" label="柜子编号" rules={[{ required: true }]}>
<Input placeholder="如:1号柜" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'available', label: '空闲' },
{ value: 'maintenance', label: '维修中' },
]}
placeholder="默认为空闲"
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
- Step 9: 可删除旧的 simple detail Modal
移除第 481-502 行的旧 Modal(如果上一步已删除则跳过)。
- Step 10: Commit
git add apps/admin/src/pages/Rooms/index.tsx
git commit -m "feat: upgrade Room detail to Drawer with Bed/Locker tabs"
Task 8: Occupancies 页面 — 入住登记改造
Files:
- Modify:
apps/admin/src/pages/Occupancies/index.tsx
Consumes: /rooms/:id/beds/available, /rooms/:id/lockers/available API
Produces: 入住表单新增床位/柜子选择,表格新增床位号/柜子号列
- Step 1: 新增状态变量
在组件 state 区新增:
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
- Step 2: 新增房间选择时的床位加载
const handleRoomChange = async (roomId: number) => {
checkInForm.setFieldValue('bedId', undefined);
checkInForm.setFieldValue('lockerId', undefined);
if (!roomId) {
setAvailableBeds([]);
setAvailableLockers([]);
return;
}
try {
const [beds, lockers] = await Promise.all([
api.get(`/rooms/${roomId}/beds/available`),
api.get(`/rooms/${roomId}/lockers/available`),
]);
setAvailableBeds(beds);
setAvailableLockers(lockers);
// 如果仅有一张可用床位,自动选中
if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id);
} catch (e) { console.error(e); }
};
- Step 3: 修改入住表单 — 在租赁方字段后新增床位/柜子
在现有入住登记 Modal 的 Form 中,tenantId Form.Item 之后插入:
<Form.Item name="bedId" label="床位" rules={[{ required: true, message: '请选择床位' }]}>
<Select
placeholder="请先选择房间"
disabled={availableBeds.length === 0}
options={availableBeds.map((b: any) => ({
value: b.id,
label: b.bedNumber,
}))}
notFoundContent="该房间暂无可用床位"
/>
</Form.Item>
{availableBeds.length > 0 && (
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
空闲 {availableBeds.length} 张床位
</div>
)}
<Form.Item name="lockerId" label="柜子(可选)">
<Select
allowClear
placeholder="可选分配柜子"
disabled={availableLockers.length === 0}
options={availableLockers.map((l: any) => ({
value: l.id,
label: l.lockerNumber,
}))}
/>
</Form.Item>
- Step 4: 修改房间选择触发加载
找到入住表单中房间的 Form.Item,给 Select 加 onChange:
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择宿舍"
onChange={handleRoomChange}
options={rooms.map((r: any) => ({
value: r.id,
label: r.roomNumber,
disabled: r.status === 'maintenance' || r.status === 'archived',
}))}
/>
</Form.Item>
- Step 5: 入住提交时传 bedId/lockerId
确认 handleCheckIn 中 payload 包含:
bedId: values.bedId,
lockerId: values.lockerId || undefined,
- Step 6: 表格新增床位号/柜子号列
在表格 columns 中,宿舍 列之后新增:
{
title: '床位', width: 80,
render: (_: any, r: any) => r.bed?.bedNumber || '-',
},
{
title: '柜子', width: 80,
render: (_: any, r: any) => r.locker?.lockerNumber || '-',
},
- Step 7: Commit
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 统计:
// 在 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 之前的位置,新增:
{/* 床位统计 */}
{room.totalBeds > 0 && (
<div style={{ fontSize: 12, color: room.occupiedBeds >= room.totalBeds ? '#ff4d4f' : '#52c41a', marginBottom: 4 }}>
床位: {room.occupiedBeds}/{room.totalBeds}
</div>
)}
- Step 3: 修改统计栏
将现有的 availableBeds(基于 capacity - currentCount)替换为基于实际 beds 统计:
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
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: 启动后端
cd apps/server && npm run start:dev &
Wait for: Nest application successfully started.
- Step 2: 测试床位 API
# 获取某房间的床位
curl -s http://localhost:3000/api/rooms/1/beds | head -c 200
# Expect: JSON 数组,包含 bedNumber, status 字段
- Step 3: 测试入住 API(带床位)
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: 启动前端验证
cd apps/admin && npm run dev
打开浏览器,验证:
- 宿舍管理 → 点击"查看住户" → 弹出 Drawer → 三个 Tab 正常切换
- 床位管理 Tab:可添加/编辑/删除/批量生成床位
- 柜子管理 Tab:同样 CRUD 操作正常
- 入住管理 → 入住登记 → 选房间后自动加载可用床位下拉
- 入住后床位状态变为"占用"
- 退宿后床位状态恢复"空闲"
- 宿舍总览卡片显示床位统计
- Step 5: 修复发现的问题并提交
git add -A
git commit -m "chore: E2E verification fixes for bed/locker management"
Self-Review
- 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)
- Placeholder scan: No TBD/TODO. All code blocks are concrete.
- Type consistency:
Bed,Lockerentity names match across tasks.bedId/lockerIdfield names consistent.CreateBedDto/UpdateBedDtoused in both service and controller. - Route ordering: Called out the critical
:roomIdvs:idroute ordering issue in Task 5.