# 多校区切换/隔离 — 实现计划 > **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, @InjectRepository(UserDepartment) private userDeptRepo: Repository, ) {} async findAll(): Promise { return this.deptRepo.find({ where: { status: 'active' }, order: { sortOrder: 'ASC', name: 'ASC' }, }); } async findTree(): Promise { 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 { const dept = await this.deptRepo.findOne({ where: { id } }); if (!dept) throw new NotFoundException('部门不存在'); return dept; } async create(dto: CreateDepartmentDto): Promise { const dept = this.deptRepo.create(dto); return this.deptRepo.save(dept); } async update(id: number, dto: UpdateDepartmentDto): Promise { const dept = await this.findOne(id); Object.assign(dept, dto); return this.deptRepo.save(dept); } async remove(id: number): Promise { // 检查是否有子部门 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 { 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 { const records = await this.userDeptRepo.find({ where: { userId }, }); return records.map((r) => r.departmentId); } /** 获取用户默认校区 ID */ async getUserDefaultDepartmentId(userId: number): Promise { const record = await this.userDeptRepo.findOne({ where: { userId, isDefault: true }, }); return record?.departmentId ?? null; } /** 获取部门下的用户 */ async getUsers(departmentId: number): Promise { return this.userDeptRepo.find({ where: { departmentId }, relations: ['user'], }); } /** 为用户分配部门 */ async assignUser(departmentId: number, dto: AssignUserDto): Promise { 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 { 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 " \ -H "Content-Type: application/json" \ -d '{"name":"鼓楼校区","type":"campus"}' # 获取树 curl http://localhost:3000/api/departments/tree \ -H "Authorization: Bearer " ``` - [ ] **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>(where: T): Promise { // 超管 + 未选校区 → 不过滤 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 { // 如果用户选了具体校区 → 该校区 + 子部门 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([]); const [currentId, setCurrentId] = useState( () => 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 ( {campuses[0]?.name || '主校区'} ); } const options = [ ...campuses.map((c) => ({ value: String(c.id), label: c.name })), { value: '', label: '全部校区' }, ]; return ( ); }; export default DepartmentsPage; ``` - [ ] **Step 2: 在 App.tsx 注册路由** ```tsx import DepartmentsPage from './pages/Departments'; // 在 Routes 内添加: }> } /> ``` - [ ] **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" ```