Files
gongxue-base/docs/superpowers/plans/2026-07-05-multi-campus.md

38 KiB
Raw Blame History

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

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

  • 表名使用复数形式:departmentsuser_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

// 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
// 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 注册导出
export { Department, DepartmentType } from './department.entity';
export { UserDepartment } from './user-department.entity';
  • Step 4: 验证 — 启动后端检查建表
cd apps/server && npm run start:dev

Expected: 启动成功,departmentsuser_departments 表自动创建。

  • Step 5: Commit
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 为例):

// 在 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
cd apps/server && npm run start:dev

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

  • Step 3: Commit
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

// 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
// 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
// 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
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

// 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.forRootAsyncallEntities 数组中添加:
Department,
UserDepartment,
  1. @Module imports 中添加:
DepartmentsModule,
  • Step 3: 验证 — 测试 API
# 创建校区
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
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

// 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
// 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 中:

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
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 为例):

// 修改前:
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 都注入了 DepartmentsModuleCampusScope 依赖它)。需要在各个需要 CampusScope 的 module 的 imports 中添加 DepartmentsModule

// 在每个受影响的 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
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

// 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 需新增字段:

@IsOptional() @IsInt()
departmentId?: number;
  • Step 2: 创建 Student 时从关联 Class 获取 department_id
// 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
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 构造处:

// 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
// 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
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 脚本

// 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
// apps/server/src/main.ts
// 在 app.listen() 之前:
const dataSource = app.get(DataSource);
await seedDefaultCampus(dataSource);
  • Step 3: 运行验证
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
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

// 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 组件
// 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
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 旁边添加:

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

// 在 Header 内 Logo 区域后:
<CampusSwitcher />
  • Step 2: API interceptor 注入 X-Campus-Id
// 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
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 管理页

// 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 注册路由
import DepartmentsPage from './pages/Departments';

// 在 Routes 内添加:
<Route path="/departments" element={
  <PrivateRoute>
    <MainLayout />
  </PrivateRoute>
}>
  <Route index element={<DepartmentsPage />} />
</Route>
  • Step 3: Commit
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: 启动完整环境
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 验证:

-- 确认历史数据已回填
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 (如有调整)
git add -A
git commit -m "fix: campus isolation tweaks and seed adjustments"