# 多校区切换/隔离 — 实现计划

> **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:** 实现多校区树形组织结构、用户-部门绑定、全局数据查询按校区自动隔离、前端校区切换器。

**Architecture:** 新增 `departments`/`user_departments` 表 + `DepartmentsModule` + 请求级 `CampusScope` Provider，各业务实体追加 `department_id` 冗余字段，Controller 通过 `scope.filter()` 自动附加校区过滤，超管绕过。

**Tech Stack:** NestJS 11, TypeORM 0.3, React 19, Ant Design 6

## Global Constraints

- 表名使用复数形式：`departments`、`user_departments`
- Entity 使用 `@Entity('table_name')` + `@Column({ name: 'snake_case' })` 模式
- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出
- Module 必须 `imports: [TypeOrmModule.forFeature([...])]`
- Controller 所有方法 `@UseGuards(JwtAuthGuard)`
- DTO 使用 class-validator 装饰器
- 前端 axios 实例从 `api/` 导入
- 前端新增路由在 `App.tsx` 注册
- 超管 (super_admin 角色) 绕过所有校区隔离
- `department_id` 采用冗余存储策略，写入时填入，避免查询时多表 JOIN

---

### Task 1: Department + UserDepartment Entity

**Files:**
- Create: `apps/server/src/entities/department.entity.ts`
- Create: `apps/server/src/entities/user-department.entity.ts`
- Modify: `apps/server/src/entities/index.ts`

**Interfaces:**
- Produces: `Department` entity, `UserDepartment` entity

- [ ] **Step 1: 创建 department.entity.ts**

```typescript
// apps/server/src/entities/department.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  ManyToOne,
  OneToMany,
  JoinColumn,
} from 'typeorm';

export enum DepartmentType {
  CAMPUS = 'campus',
  DEPARTMENT = 'department',
}

@Entity('departments')
export class Department {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  name: string;

  @Column({ name: 'parent_id', type: 'integer', nullable: true })
  parentId: number;

  @ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL' })
  @JoinColumn({ name: 'parent_id' })
  parent: Department;

  @OneToMany(() => Department, (d) => d.parent)
  children: Department[];

  @Column({ length: 20, default: DepartmentType.DEPARTMENT })
  type: string;

  @Column({ name: 'sort_order', type: 'integer', default: 0 })
  sortOrder: number;

  @Column({ length: 20, default: 'active' })
  status: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}
```

- [ ] **Step 2: 创建 user-department.entity.ts**

```typescript
// apps/server/src/entities/user-department.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  ManyToOne,
  JoinColumn,
  Unique,
} from 'typeorm';
import { User } from './user.entity';
import { Department } from './department.entity';

@Entity('user_departments')
@Unique(['userId', 'departmentId'])
export class UserDepartment {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: 'user_id', type: 'integer' })
  userId: number;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: User;

  @Column({ name: 'department_id', type: 'integer' })
  departmentId: number;

  @ManyToOne(() => Department, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'department_id' })
  department: Department;

  @Column({ name: 'is_default', type: 'boolean', default: false })
  isDefault: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}
```

- [ ] **Step 3: 在 entities/index.ts 注册导出**

```typescript
export { Department, DepartmentType } from './department.entity';
export { UserDepartment } from './user-department.entity';
```

- [ ] **Step 4: 验证 — 启动后端检查建表**

```bash
cd apps/server && npm run start:dev
```

Expected: 启动成功，`departments`、`user_departments` 表自动创建。

- [ ] **Step 5: Commit**

```bash
git add apps/server/src/entities/department.entity.ts apps/server/src/entities/user-department.entity.ts apps/server/src/entities/index.ts
git commit -m "feat: add Department and UserDepartment entities"
```

---

### Task 2: 现有实体追加 department_id

**Files:**
- Modify: `apps/server/src/entities/student.entity.ts`
- Modify: `apps/server/src/entities/room.entity.ts`
- Modify: `apps/server/src/entities/classroom.entity.ts`
- Modify: `apps/server/src/entities/class-schedule.entity.ts`
- Modify: `apps/server/src/entities/attendance-record.entity.ts`
- Modify: `apps/server/src/entities/room-expense.entity.ts`
- Modify: `apps/server/src/entities/personal-expense.entity.ts`
- Modify: `apps/server/src/entities/occupancy.entity.ts`
- Modify: `apps/server/src/entities/bill.entity.ts`
- Modify: `apps/server/src/entities/deposit.entity.ts`
- Modify: `apps/server/src/entities/deposit-installment.entity.ts`
- Modify: `apps/server/src/entities/classroom-rental.entity.ts`

**Interfaces:**
- Consumes: `Department` entity from Task 1
- Produces: 所有业务实体新增 `departmentId` 字段 + `@ManyToOne` 关系

- [ ] **Step 1: 批量追加 department_id**

对每个实体，追加以下代码块（以 `student.entity.ts` 为例）：

```typescript
// 在 imports 中添加:
import { Department } from './department.entity';

// 在类体中添加:
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;

@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
```

追加清单：

| 文件 | 说明 |
|------|------|
| `student.entity.ts` | 学生归属校区 |
| `room.entity.ts` | 宿舍归属校区 |
| `classroom.entity.ts` | 教室归属校区 |
| `class-schedule.entity.ts` | 排课归属校区（冗余） |
| `attendance-record.entity.ts` | 考勤归属校区（冗余） |
| `room-expense.entity.ts` | 宿舍费用归属校区（冗余） |
| `personal-expense.entity.ts` | 个人费用归属校区（冗余） |
| `occupancy.entity.ts` | 入住归属校区（冗余） |
| `bill.entity.ts` | 账单归属校区（冗余） |
| `deposit.entity.ts` | 押金归属校区（冗余） |
| `deposit-installment.entity.ts` | 押金分期归属校区（冗余） |
| `classroom-rental.entity.ts` | 租赁订单归属校区（冗余） |

注：`classes` 实体已有 `departmentId`，跳过。

- [ ] **Step 2: 验证 — TypeORM 自动 ALTER TABLE**

```bash
cd apps/server && npm run start:dev
```

Expected: 启动成功，所有业务表新增 `department_id` 列。

- [ ] **Step 3: Commit**

```bash
git add apps/server/src/entities/
git commit -m "feat: add department_id to all business entities"
```

---

### Task 3: DepartmentsModule — DTO + Service

**Files:**
- Create: `apps/server/src/departments/dto/department.dto.ts`
- Create: `apps/server/src/departments/departments.service.ts`
- Create: `apps/server/src/departments/departments.module.ts`

**Interfaces:**
- Consumes: `Department` + `UserDepartment` entities from Task 1
- Produces: `DepartmentsService` with `findAll`, `findTree`, `create`, `update`, `remove`, `getDescendantIds`, `getUserDepartments`, `assignUser`, `removeUser`

- [ ] **Step 1: 创建 DTO**

```typescript
// apps/server/src/departments/dto/department.dto.ts
import { IsString, IsNotEmpty, IsOptional, IsInt, IsBoolean, IsEnum } from 'class-validator';

export class CreateDepartmentDto {
  @IsString() @IsNotEmpty()
  name: string;

  @IsOptional() @IsInt()
  parentId?: number;

  @IsOptional() @IsString()
  type?: string;

  @IsOptional() @IsInt()
  sortOrder?: number;
}

export class UpdateDepartmentDto {
  @IsOptional() @IsString()
  name?: string;

  @IsOptional() @IsInt()
  parentId?: number;

  @IsOptional() @IsString()
  type?: string;

  @IsOptional() @IsInt()
  sortOrder?: number;
}

export class AssignUserDto {
  @IsInt()
  userId: number;

  @IsOptional() @IsBoolean()
  isDefault?: boolean;
}
```

- [ ] **Step 2: 创建 Service**

```typescript
// apps/server/src/departments/departments.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Department } from '../entities/department.entity';
import { UserDepartment } from '../entities/user-department.entity';
import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto';

@Injectable()
export class DepartmentsService {
  constructor(
    @InjectRepository(Department)
    private deptRepo: Repository<Department>,
    @InjectRepository(UserDepartment)
    private userDeptRepo: Repository<UserDepartment>,
  ) {}

  async findAll(): Promise<Department[]> {
    return this.deptRepo.find({
      where: { status: 'active' },
      order: { sortOrder: 'ASC', name: 'ASC' },
    });
  }

  async findTree(): Promise<Department[]> {
    const all = await this.deptRepo.find({
      where: { status: 'active' },
      order: { sortOrder: 'ASC', name: 'ASC' },
      relations: ['children'],
    });
    // 返回根节点（parent_id = null）
    return all.filter((d) => d.parentId === null);
  }

  async findOne(id: number): Promise<Department> {
    const dept = await this.deptRepo.findOne({ where: { id } });
    if (!dept) throw new NotFoundException('部门不存在');
    return dept;
  }

  async create(dto: CreateDepartmentDto): Promise<Department> {
    const dept = this.deptRepo.create(dto);
    return this.deptRepo.save(dept);
  }

  async update(id: number, dto: UpdateDepartmentDto): Promise<Department> {
    const dept = await this.findOne(id);
    Object.assign(dept, dto);
    return this.deptRepo.save(dept);
  }

  async remove(id: number): Promise<void> {
    // 检查是否有子部门
    const children = await this.deptRepo.count({ where: { parentId: id } });
    if (children > 0) throw new ConflictException('该部门下存在子部门，无法删除');

    // 检查是否有关联用户
    const users = await this.userDeptRepo.count({ where: { departmentId: id } });
    if (users > 0) throw new ConflictException('该部门下有用户关联，无法删除');

    await this.deptRepo.update(id, { status: 'archived' });
  }

  /** 获取部门的所有子部门 ID（递归，含自身） */
  async getDescendantIds(departmentId: number): Promise<number[]> {
    const ids = [departmentId];
    const children = await this.deptRepo.find({
      where: { parentId: departmentId, status: 'active' },
    });
    for (const child of children) {
      const childIds = await this.getDescendantIds(child.id);
      ids.push(...childIds);
    }
    return ids;
  }

  /** 获取用户可访问的部门 ID 列表 */
  async getUserDepartments(userId: number): Promise<number[]> {
    const records = await this.userDeptRepo.find({
      where: { userId },
    });
    return records.map((r) => r.departmentId);
  }

  /** 获取用户默认校区 ID */
  async getUserDefaultDepartmentId(userId: number): Promise<number | null> {
    const record = await this.userDeptRepo.findOne({
      where: { userId, isDefault: true },
    });
    return record?.departmentId ?? null;
  }

  /** 获取部门下的用户 */
  async getUsers(departmentId: number): Promise<UserDepartment[]> {
    return this.userDeptRepo.find({
      where: { departmentId },
      relations: ['user'],
    });
  }

  /** 为用户分配部门 */
  async assignUser(departmentId: number, dto: AssignUserDto): Promise<UserDepartment> {
    const record = this.userDeptRepo.create({
      userId: dto.userId,
      departmentId,
      isDefault: dto.isDefault ?? false,
    });
    return this.userDeptRepo.save(record);
  }

  /** 移除用户-部门关联 */
  async removeUser(departmentId: number, userId: number): Promise<void> {
    await this.userDeptRepo.delete({ departmentId, userId });
  }
}
```

- [ ] **Step 3: 创建 Module**

```typescript
// apps/server/src/departments/departments.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Department } from '../entities/department.entity';
import { UserDepartment } from '../entities/user-department.entity';
import { DepartmentsService } from './departments.service';
import { DepartmentsController } from './departments.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Department, UserDepartment])],
  controllers: [DepartmentsController],
  providers: [DepartmentsService],
  exports: [DepartmentsService],
})
export class DepartmentsModule {}
```

- [ ] **Step 4: Commit**

```bash
git add apps/server/src/departments/
git commit -m "feat: add DepartmentsService with tree query and user assignment"
```

---

### Task 4: DepartmentsController

**Files:**
- Create: `apps/server/src/departments/departments.controller.ts`
- Modify: `apps/server/src/app.module.ts` — 注册 DepartmentsModule + 在 TypeORM entities 中添加 Department/UserDepartment

**Interfaces:**
- Consumes: `DepartmentsService` from Task 3
- Produces: REST API

- [ ] **Step 1: 创建 Controller**

```typescript
// apps/server/src/departments/departments.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  UseGuards,
} from '@nestjs/common';
import { DepartmentsService } from './departments.service';
import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';

@UseGuards(JwtAuthGuard)
@Controller('departments')
export class DepartmentsController {
  constructor(private readonly service: DepartmentsService) {}

  @Get()
  findAll() {
    return this.service.findAll();
  }

  @Get('tree')
  findTree() {
    return this.service.findTree();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.service.findOne(+id);
  }

  @Post()
  create(@Body() dto: CreateDepartmentDto) {
    return this.service.create(dto);
  }

  @Put(':id')
  update(@Param('id') id: string, @Body() dto: UpdateDepartmentDto) {
    return this.service.update(+id, dto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.service.remove(+id);
  }

  @Get(':id/users')
  getUsers(@Param('id') id: string) {
    return this.service.getUsers(+id);
  }

  @Post(':id/users')
  assignUser(@Param('id') id: string, @Body() dto: AssignUserDto) {
    return this.service.assignUser(+id, dto);
  }

  @Delete(':id/users/:userId')
  removeUser(@Param('id') id: string, @Param('userId') userId: string) {
    return this.service.removeUser(+id, +userId);
  }
}
```

- [ ] **Step 2: 注册到 AppModule**

在 `apps/server/src/app.module.ts` 中：

1. 在 `TypeOrmModule.forRootAsync` 的 `allEntities` 数组中添加：
```typescript
Department,
UserDepartment,
```

2. 在 `@Module imports` 中添加：
```typescript
DepartmentsModule,
```

- [ ] **Step 3: 验证 — 测试 API**

```bash
# 创建校区
curl -X POST http://localhost:3000/api/departments \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"鼓楼校区","type":"campus"}'

# 获取树
curl http://localhost:3000/api/departments/tree \
  -H "Authorization: Bearer <token>"
```

- [ ] **Step 4: Commit**

```bash
git add apps/server/src/departments/departments.controller.ts apps/server/src/app.module.ts
git commit -m "feat: add DepartmentsController with CRUD + user assignment"
```

---

### Task 5: CampusScope + 请求级注入

**Files:**
- Create: `apps/server/src/common/campus-scope.ts`
- Modify: `apps/server/src/app.module.ts` — 注册 CampusScope provider

**Interfaces:**
- Produces: `CampusScope` 请求级 Provider，通过 `scope.filter()` 在查询条件中自动追加 `departmentId`

- [ ] **Step 1: 创建 CampusScope**

```typescript
// apps/server/src/common/campus-scope.ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { In } from 'typeorm';
import { DepartmentsService } from '../departments/departments.service';

@Injectable({ scope: Scope.REQUEST })
export class CampusScope {
  constructor(
    @Inject(REQUEST) private req: any,
    private departmentsService: DepartmentsService,
  ) {}

  get userId(): number {
    return this.req.user?.id;
  }

  get isSuperAdmin(): boolean {
    return this.req.user?.isSuperAdmin ?? false;
  }

  get currentDepartmentId(): number | null {
    const headerId = parseInt(this.req.headers?.['x-campus-id'] || '0', 10);
    return headerId || null;
  }

  /** 对 TypeORM find where 条件追加校区过滤 */
  async filter<T extends Record<string, any>>(where: T): Promise<T> {
    // 超管 + 未选校区 → 不过滤
    if (this.isSuperAdmin && !this.currentDepartmentId) {
      return where;
    }

    const ids = await this.getEffectiveScopeIds();
    if (ids.length === 0) return where;

    return { ...where, departmentId: In(ids) } as any;
  }

  private async getEffectiveScopeIds(): Promise<number[]> {
    // 如果用户选了具体校区 → 该校区 + 子部门
    if (this.currentDepartmentId) {
      return this.departmentsService.getDescendantIds(this.currentDepartmentId);
    }

    // 未选校区 → 用户关联的所有部门 + 子部门
    const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);
    const allIds = await Promise.all(
      userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)),
    );
    return [...new Set(allIds.flat())];
  }
}
```

- [ ] **Step 2: 创建 CampusScopeMiddleware 将 scope 注入 req**

```typescript
// apps/server/src/common/campus-scope.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { CampusScope } from './campus-scope';

@Injectable()
export class CampusScopeMiddleware implements NestMiddleware {
  constructor(private readonly scope: CampusScope) {}

  use(req: Request, _res: Response, next: NextFunction) {
    (req as any).campusScope = this.scope;
    next();
  }
}
```

- [ ] **Step 3: 在 AppModule 中注册**

在 `apps/server/src/app.module.ts` 中：

```typescript
import { CampusScope } from './common/campus-scope';
import { CampusScopeMiddleware } from './common/campus-scope.middleware';
import { MiddlewareConsumer, NestModule } from '@nestjs/common';

// 在 providers 中添加:
CampusScope,

// AppModule 实现 NestModule:
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(CampusScopeMiddleware).forRoutes('*');
  }
}
```

- [ ] **Step 4: Commit**

```bash
git add apps/server/src/common/campus-scope.ts apps/server/src/common/campus-scope.middleware.ts apps/server/src/app.module.ts
git commit -m "feat: add CampusScope request-level provider for data isolation"
```

---

### Task 6: 各 Service 接入 CampusScope.filter()

**Files:**
- Modify: `apps/server/src/students/students.service.ts`
- Modify: `apps/server/src/rooms/rooms.service.ts`
- Modify: `apps/server/src/occupancies/occupancies.service.ts`
- Modify: `apps/server/src/bills/bills.service.ts`
- Modify: `apps/server/src/expenses/expenses.service.ts`
- Modify: `apps/server/src/classes/classes.service.ts`
- Modify: `apps/server/src/schedules/schedules.service.ts`
- Modify: `apps/server/src/attendance/attendance.service.ts`
- Modify: `apps/server/src/deposits/deposits.service.ts`
- Modify: `apps/server/src/classrooms/classrooms.service.ts`
- Modify: `apps/server/src/classroom-rentals/classroom-rentals.service.ts`
- Modify: `apps/server/src/dashboard/dashboard.service.ts`

**Interfaces:**
- Consumes: `CampusScope` from Task 5
- Modifies: 所有查询方法通过 `scope.filter()` 追加校区过滤

- [ ] **Step 1: 模式说明**

每个 service 的查询方法改造模式（以 `StudentsService.findAll` 为例）：

```typescript
// 修改前:
async findAll(query: any) {
  return this.repo.find({ where: { status: 'active' } });
}

// 修改后:
async findAll(query: any) {
  const where = await this.scope.filter({ status: 'active' });
  return this.repo.find({ where });
}
```

- [ ] **Step 2: 各 Service 注入 CampusScope**

对于每个 Controller/Service 文件，需要：
1. `import { CampusScope } from '../common/campus-scope';`
2. constructor 注入: `private scope: CampusScope`
3. 所有 `find`/`findAndCount`/`createQueryBuilder` 查询用 `await this.scope.filter(where)` 包裹

但不是所有 module 都注入了 `DepartmentsModule`（CampusScope 依赖它）。需要在各个需要 CampusScope 的 module 的 imports 中添加 `DepartmentsModule`。

```typescript
// 在每个受影响的 module 中添加:
import { DepartmentsModule } from '../departments/departments.module';

@Module({
  imports: [
    TypeOrmModule.forFeature([...]),
    DepartmentsModule,  // 新增
  ],
})
```

- [ ] **Step 3: 改造清单**

逐个 service 改造 `findAll` 类方法（有查询条件的方法）：

| Service | 方法 | 操作 |
|---------|------|------|
| `students.service.ts` | `findAll` | `await this.scope.filter(where)` |
| `rooms.service.ts` | `findAll`, `findVisual` | 同上 |
| `occupancies.service.ts` | `findAll` | 同上 |
| `bills.service.ts` | `findAll` | 同上 |
| `expenses.service.ts` | 所有查询 | 同上 |
| `classes.service.ts` | `findAll` | 同上 |
| `schedules.service.ts` | `findAll`, `findWeekly` | 同上 |
| `attendance.service.ts` | 所有查询 | 同上 |
| `deposits.service.ts` | 所有查询 | 同上 |
| `classrooms.service.ts` | `findAll` | 同上 |
| `classroom-rentals.service.ts` | `findAll` | 同上 |
| `dashboard.service.ts` | `getStats` | 同上 |

- [ ] **Step 4: Commit**

```bash
git add apps/server/src/
git commit -m "feat: integrate CampusScope.filter() into all business services"
```

---

### Task 7: 写操作时填充 department_id

**Files:**
- Modify: 各 Service 的 create/update 方法

**Interfaces:**
- Modifies: 创建记录时自动填充 `departmentId`（从关联实体或前端输入获取）

- [ ] **Step 1: 创建 Room 时填充 department_id**

```typescript
// rooms.service.ts create()
async create(dto: CreateRoomDto) {
  const room = this.repo.create({
    ...dto,
    departmentId: dto.departmentId, // 前端传入（由校区选择器当前选中值决定）
  });
  return this.repo.save(room);
}
```

`rooms.dto.ts` 需新增字段：
```typescript
@IsOptional() @IsInt()
departmentId?: number;
```

- [ ] **Step 2: 创建 Student 时从关联 Class 获取 department_id**

```typescript
// students.service.ts create()
async create(dto: CreateStudentDto) {
  let departmentId = dto.departmentId;
  if (!departmentId && dto.classId) {
    const cls = await this.classesRepo.findOne({ where: { id: dto.classId } });
    departmentId = cls?.departmentId || null;
  }
  const student = this.repo.create({ ...dto, departmentId });
  return this.repo.save(student);
}
```

- [ ] **Step 3: 其他实体类似处理**

| 实体创建时 | department_id 来源 |
|-----------|-------------------|
| Occupancy | 从关联的 Room.departmentId |
| Bill | 从关联的 Student.departmentId |
| PersonalExpense | 从关联的 Student.departmentId |
| RoomExpense | 从关联的 Room.departmentId |
| AttendanceRecord | 从关联的 Student.departmentId |
| Deposit | 从关联的 Student.departmentId |
| DepositInstallment | 从关联的 Deposit.departmentId |
| ClassSchedule | 从关联的 Class.departmentId 或前端传入 |
| ClassroomRental | 从关联的 Classroom.departmentId |
| Classroom | 前端传入 |

- [ ] **Step 4: Commit**

```bash
git add apps/server/src/
git commit -m "feat: auto-populate department_id on entity creation"
```

---

### Task 8: JWT Payload 扩展 + 登录注入 isSuperAdmin

**Files:**
- Modify: `apps/server/src/auth/auth.service.ts`
- Modify: `apps/server/src/auth/strategies/jwt.strategy.ts`

**Interfaces:**
- Modifies: JWT payload 增加 `isSuperAdmin` 标记

- [ ] **Step 1: auth.service.ts — login() 注入 isSuperAdmin**

在 `login()` 方法的 payload 构造处：

```typescript
// auth.service.ts
const isSuperAdmin = user.roles?.some(r => r.name === 'super_admin') ?? false;
const payload = {
  sub: user.id,
  username: user.username,
  permissions,
  isSuperAdmin,
};
```

- [ ] **Step 2: jwt.strategy.ts — validate() 透传 isSuperAdmin**

```typescript
// jwt.strategy.ts validate()
async validate(payload: any) {
  return {
    id: payload.sub,
    username: payload.username,
    permissions: payload.permissions || [],
    isSuperAdmin: payload.isSuperAdmin || false,
  };
}
```

- [ ] **Step 3: Commit**

```bash
git add apps/server/src/auth/
git commit -m "feat: add isSuperAdmin to JWT payload for campus scope bypass"
```

---

### Task 9: 数据回填 — 默认校区 + 历史数据迁移

**Files:**
- Create: `apps/server/src/departments/seed.service.ts`（或直接用 migration script）

- [ ] **Step 1: 创建 seed 脚本**

```typescript
// apps/server/src/departments/seed.ts
// 在 NestJS bootstrap 后执行（或作为独立脚本运行）
import { DataSource } from 'typeorm';

export async function seedDefaultCampus(dataSource: DataSource) {
  const deptRepo = dataSource.getRepository('departments');
  const userDeptRepo = dataSource.getRepository('user_departments');

  // 1. 检查是否已有校区数据
  const existing = await deptRepo.count();
  if (existing > 0) {
    console.log('Departments already exist, skipping seed');
    return;
  }

  // 2. 创建默认校区
  const campus = await deptRepo.save({
    name: '主校区',
    type: 'campus',
    sortOrder: 0,
  });

  // 3. 回填所有业务数据的 department_id
  const tables = [
    'students', 'rooms', 'classrooms', 'class_schedules',
    'attendance_records', 'room_expenses', 'personal_expenses',
    'occupancies', 'bills', 'deposits', 'deposit_installments',
    'classroom_rentals',
  ];

  for (const table of tables) {
    await dataSource.query(
      `UPDATE ${table} SET department_id = ? WHERE department_id IS NULL`,
      [campus.id],
    );
  }

  // 4. 所有现有用户关联到默认校区
  const users = await dataSource.query('SELECT id FROM users');
  for (const user of users) {
    await userDeptRepo.save({
      userId: user.id,
      departmentId: campus.id,
      isDefault: true,
    });
  }

  console.log('Seed complete: default campus created, data backfilled');
}
```

- [ ] **Step 2: 在 main.ts 中注册 seed**

```typescript
// apps/server/src/main.ts
// 在 app.listen() 之前:
const dataSource = app.get(DataSource);
await seedDefaultCampus(dataSource);
```

- [ ] **Step 3: 运行验证**

```bash
cd apps/server && npm run start:dev
```

Expected: 启动日志显示 "Seed complete: default campus created, data backfilled"。

验证：查询 `SELECT COUNT(*) FROM students WHERE department_id IS NULL` 应为 0。

- [ ] **Step 4: Commit**

```bash
git add apps/server/src/departments/seed.ts apps/server/src/main.ts
git commit -m "feat: add default campus seed with historical data backfill"
```

---

### Task 10: 前端 — useCampus Hook + CampusSwitcher 组件

**Files:**
- Create: `apps/admin/src/hooks/useCampus.ts`
- Create: `apps/admin/src/components/CampusSwitcher.tsx`

**Interfaces:**
- Consumes: `GET /api/departments/tree` + `GET /api/departments` (for flat list)
- Produces: `useCampus` hook, `CampusSwitcher` component

- [ ] **Step 1: 创建 useCampus hook**

```typescript
// apps/admin/src/hooks/useCampus.ts
import { useState, useEffect, useCallback } from 'react';
import api from '../api';

interface Department {
  id: number;
  name: string;
  type: string;
  parentId: number | null;
}

export function useCampus() {
  const [campuses, setCampuses] = useState<Department[]>([]);
  const [currentId, setCurrentId] = useState<string>(
    () => localStorage.getItem('currentCampusId') || ''
  );
  const [loading, setLoading] = useState(true);

  const fetchCampuses = useCallback(async () => {
    try {
      // 只取校区级部门（type=campus）
      const data = await api.get('/departments') as unknown as Department[];
      const campusList = data.filter((d) => d.type === 'campus');
      setCampuses(campusList);

      // 如果没有选中校区，选第一个
      if (!currentId && campusList.length > 0) {
        setCurrentId(String(campusList[0].id));
        localStorage.setItem('currentCampusId', String(campusList[0].id));
      }
    } catch {
      // 静默失败
    } finally {
      setLoading(false);
    }
  }, [currentId]);

  useEffect(() => {
    fetchCampuses();
  }, []);

  const switchCampus = useCallback((id: string) => {
    setCurrentId(id);
    localStorage.setItem('currentCampusId', id);
    // 触发全局数据刷新
    window.dispatchEvent(new CustomEvent('campus-changed', { detail: id }));
  }, []);

  return { campuses, currentId, switchCampus, loading };
}
```

- [ ] **Step 2: 创建 CampusSwitcher 组件**

```tsx
// apps/admin/src/components/CampusSwitcher.tsx
import React from 'react';
import { Select, Typography } from 'antd';
import { EnvironmentOutlined } from '@ant-design/icons';
import { useCampus } from '../hooks/useCampus';

const CampusSwitcher: React.FC = () => {
  const { campuses, currentId, switchCampus, loading } = useCampus();

  // 只有一个校区 → 纯文本展示
  if (campuses.length <= 1) {
    return (
      <Typography.Text style={{ color: '#fff', marginRight: 24 }}>
        <EnvironmentOutlined style={{ marginRight: 4 }} />
        {campuses[0]?.name || '主校区'}
      </Typography.Text>
    );
  }

  const options = [
    ...campuses.map((c) => ({ value: String(c.id), label: c.name })),
    { value: '', label: '全部校区' },
  ];

  return (
    <Select
      value={currentId || undefined}
      onChange={switchCampus}
      options={options}
      loading={loading}
      style={{ minWidth: 140, marginRight: 24 }}
      variant="borderless"
      popupMatchSelectWidth={false}
    />
  );
};

export default CampusSwitcher;
```

- [ ] **Step 3: Commit**

```bash
git add apps/admin/src/hooks/useCampus.ts apps/admin/src/components/CampusSwitcher.tsx
git commit -m "feat: add useCampus hook and CampusSwitcher component"
```

---

### Task 11: 前端 — MainLayout 集成校区选择器 + API interceptor

**Files:**
- Modify: `apps/admin/src/layouts/MainLayout.tsx`
- Modify: `apps/admin/src/api/index.ts`

- [ ] **Step 1: MainLayout 添加 CampusSwitcher**

在 `MainLayout.tsx` 的 Header 中，Logo 旁边添加：

```tsx
import CampusSwitcher from '../components/CampusSwitcher';

// 在 Header 内 Logo 区域后:
<CampusSwitcher />
```

- [ ] **Step 2: API interceptor 注入 X-Campus-Id**

```typescript
// apps/admin/src/api/index.ts
// 在已有的 request interceptor 中添加:
api.interceptors.request.use((config) => {
  // ... 已有的 token 注入 ...

  const campusId = localStorage.getItem('currentCampusId');
  if (campusId) {
    config.headers['X-Campus-Id'] = campusId;
  }
  return config;
});
```

- [ ] **Step 3: Commit**

```bash
git add apps/admin/src/layouts/MainLayout.tsx apps/admin/src/api/index.ts
git commit -m "feat: integrate CampusSwitcher into header and API interceptor"
```

---

### Task 12: 前端 — 部门管理页

**Files:**
- Create: `apps/admin/src/pages/Departments/index.tsx`
- Modify: `apps/admin/src/App.tsx` — 注册路由

- [ ] **Step 1: 创建 Departments 管理页**

```tsx
// apps/admin/src/pages/Departments/index.tsx
import React, { useState, useEffect } from 'react';
import {
  Tree, Card, Button, Modal, Form, Input, Select, InputNumber,
  Space, Table, Popconfirm, message, Row, Col,
} from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import api from '../../api';

interface Department {
  id: number;
  name: string;
  parentId: number | null;
  type: string;
  sortOrder: number;
  children?: Department[];
}

const DepartmentsPage: React.FC = () => {
  const [tree, setTree] = useState<Department[]>([]);
  const [selected, setSelected] = useState<Department | null>(null);
  const [modalOpen, setModalOpen] = useState(false);
  const [editing, setEditing] = useState<Department | null>(null);
  const [users, setUsers] = useState<any[]>([]);
  const [form] = Form.useForm();

  const fetchTree = async () => {
    try {
      const data = await api.get('/departments/tree') as unknown as Department[];
      setTree(data);
    } catch { /* ignore */ }
  };

  useEffect(() => { fetchTree(); }, []);

  const handleSelect = async (keys: React.Key[]) => {
    if (keys.length === 0) return;
    try {
      const dept = await api.get(`/departments/${keys[0]}`) as unknown as Department;
      setSelected(dept);
      const userData = await api.get(`/departments/${keys[0]}/users`) as unknown as any[];
      setUsers(userData);
    } catch { /* ignore */ }
  };

  const handleSave = async () => {
    const values = await form.validateFields();
    try {
      if (editing) {
        await api.put(`/departments/${editing.id}`, values);
        message.success('更新成功');
      } else {
        await api.post('/departments', values);
        message.success('创建成功');
      }
      setModalOpen(false);
      fetchTree();
    } catch (e: any) {
      message.error(e?.message || '操作失败');
    }
  };

  const handleDelete = async (id: number) => {
    try {
      await api.delete(`/departments/${id}`);
      message.success('已删除');
      setSelected(null);
      fetchTree();
    } catch (e: any) {
      message.error(e?.message || '删除失败');
    }
  };

  const openCreate = (parentId?: number) => {
    setEditing(null);
    form.resetFields();
    form.setFieldsValue({ parentId: parentId ?? null, type: 'department', sortOrder: 0 });
    setModalOpen(true);
  };

  const openEdit = () => {
    if (!selected) return;
    setEditing(selected);
    form.setFieldsValue(selected);
    setModalOpen(true);
  };

  const treeData = tree.map((node) => ({
    title: `${node.name} (${node.type === 'campus' ? '校区' : '部门'})`,
    key: node.id,
    children: node.children?.map((child) => ({
      title: `${child.name} (${child.type === 'campus' ? '校区' : '部门'})`,
      key: child.id,
    })),
  }));

  return (
    <Row gutter={24}>
      <Col span={8}>
        <Card
          title="组织架构"
          extra={
            <Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => openCreate()}>
              新增校区
            </Button>
          }
        >
          <Tree
            treeData={treeData}
            onSelect={handleSelect}
            style={{ minHeight: 400 }}
          />
        </Card>
      </Col>
      <Col span={16}>
        {selected ? (
          <Card
            title={selected.name}
            extra={
              <Space>
                <Button size="small" icon={<PlusOutlined />} onClick={() => openCreate(selected.id)}>
                  添加子部门
                </Button>
                <Button size="small" icon={<EditOutlined />} onClick={openEdit}>
                  编辑
                </Button>
                <Popconfirm title="确定删除？" onConfirm={() => handleDelete(selected.id)}>
                  <Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
                </Popconfirm>
              </Space>
            }
          >
            <p>类型：{selected.type === 'campus' ? '校区' : '部门'}</p>
            <p>排序：{selected.sortOrder}</p>
            <h4>部门成员</h4>
            <Table
              dataSource={users}
              rowKey="id"
              columns={[
                { title: '用户名', dataIndex: ['user', 'username'] },
                { title: '姓名', dataIndex: ['user', 'name'] },
                { title: '默认校区', dataIndex: 'isDefault', render: (v: boolean) => v ? '是' : '否' },
              ]}
              size="small"
            />
          </Card>
        ) : (
          <Card>
            <div style={{ textAlign: 'center', color: '#999', padding: 40 }}>
              请从左侧选择一个部门
            </div>
          </Card>
        )}
      </Col>

      <Modal
        title={editing ? '编辑部门' : '新增部门'}
        open={modalOpen}
        onOk={handleSave}
        onCancel={() => setModalOpen(false)}
      >
        <Form form={form} layout="vertical">
          <Form.Item name="name" label="名称" rules={[{ required: true }]}>
            <Input />
          </Form.Item>
          <Form.Item name="parentId" label="上级部门">
            <Select
              allowClear
              options={[
                ...tree.map((d) => ({ value: d.id, label: d.name })),
              ]}
            />
          </Form.Item>
          <Form.Item name="type" label="类型">
            <Select
              options={[
                { value: 'campus', label: '校区' },
                { value: 'department', label: '部门' },
              ]}
            />
          </Form.Item>
          <Form.Item name="sortOrder" label="排序">
            <InputNumber min={0} />
          </Form.Item>
        </Form>
      </Modal>
    </Row>
  );
};

export default DepartmentsPage;
```

- [ ] **Step 2: 在 App.tsx 注册路由**

```tsx
import DepartmentsPage from './pages/Departments';

// 在 Routes 内添加:
<Route path="/departments" element={
  <PrivateRoute>
    <MainLayout />
  </PrivateRoute>
}>
  <Route index element={<DepartmentsPage />} />
</Route>
```

- [ ] **Step 3: Commit**

```bash
git add apps/admin/src/pages/Departments/index.tsx apps/admin/src/App.tsx
git commit -m "feat: add Departments management page with tree + user list"
```

---

### Task 13: 验证 + 端到端测试

- [ ] **Step 1: 启动完整环境**

```bash
cd apps/server && npm run start:dev &
cd apps/admin && npm run dev &
```

- [ ] **Step 2: 测试流程**

1. 打开 `http://localhost:5173`，用超管登录
2. 访问 `/departments` → 确认默认「主校区」存在
3. 创建第二个校区「江宁校区」
4. 进入「账号管理」→ 编辑某个教职工，将其分配到「江宁校区」
5. 用该教职工登录 → Header 显示校区选择器，可切换
6. 切换到「江宁校区」→ 学生列表/宿舍列表只显示江宁数据
7. 切换到「全部校区」→ 显示两个校区数据
8. 超管不选校区 → 显示全部数据（无隔离）

- [ ] **Step 3: 数据隔离验证**

SQL 验证：
```sql
-- 确认历史数据已回填
SELECT COUNT(*) FROM students WHERE department_id IS NULL;  -- 期望 0
SELECT COUNT(*) FROM rooms WHERE department_id IS NULL;    -- 期望 0

-- 确认新创建实体自动填充
INSERT INTO students (...) VALUES (...);
-- 应自动填 department_id
```

- [ ] **Step 4: Commit (如有调整)**

```bash
git add -A
git commit -m "fix: campus isolation tweaks and seed adjustments"
```
