1495 lines
50 KiB
Markdown
1495 lines
50 KiB
Markdown
# 床位管理 & 柜子管理 — 实现计划
|
||
|
||
> **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<Bed>,
|
||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||
```
|
||
|
||
- [ ] **Step 3: 新增床位 CRUD 方法**
|
||
|
||
```typescript
|
||
// 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 方法**
|
||
|
||
```typescript
|
||
// 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**
|
||
|
||
```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<Bed>,
|
||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||
```
|
||
|
||
**修改 `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<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: 新增床位/柜子数据加载**
|
||
|
||
```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
|
||
<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 改为:
|
||
|
||
```typescript
|
||
onClick={async () => {
|
||
setDrawerRoom(record);
|
||
setDrawerOpen(true);
|
||
// 异步加载床位和柜子
|
||
await Promise.all([fetchBeds(record.id), fetchLockers(record.id)]);
|
||
}}
|
||
```
|
||
|
||
- [ ] **Step 8: 新增床位/柜子编辑 Modal**
|
||
|
||
在 Drawer 之外(放在现有"添加宿舍"Modal 之后、最终 `</div>` 之前),新增两个小型 Modal:
|
||
|
||
```typescript
|
||
{/* 床位编辑弹窗 */}
|
||
<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**
|
||
|
||
```bash
|
||
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 区新增:
|
||
|
||
```typescript
|
||
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
||
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
||
```
|
||
|
||
- [ ] **Step 2: 新增房间选择时的床位加载**
|
||
|
||
```typescript
|
||
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 之后插入:
|
||
|
||
```typescript
|
||
<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`:
|
||
|
||
```typescript
|
||
<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 包含:
|
||
|
||
```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 && (
|
||
<div style={{ fontSize: 12, color: room.occupiedBeds >= room.totalBeds ? '#ff4d4f' : '#52c41a', marginBottom: 4 }}>
|
||
床位: {room.occupiedBeds}/{room.totalBeds}
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
- [ ] **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.
|