38 KiB
多校区切换/隔离 — 实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 实现多校区树形组织结构、用户-部门绑定、全局数据查询按校区自动隔离、前端校区切换器。
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:
Departmententity,UserDepartmententity -
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: 启动成功,departments、user_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:
Departmententity 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+UserDepartmententities from Task 1 -
Produces:
DepartmentsServicewithfindAll,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:
DepartmentsServicefrom 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 中:
- 在
TypeOrmModule.forRootAsync的allEntities数组中添加:
Department,
UserDepartment,
- 在
@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:
CampusScopefrom 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 文件,需要:
import { CampusScope } from '../common/campus-scope';- constructor 注入:
private scope: CampusScope - 所有
find/findAndCount/createQueryBuilder查询用await this.scope.filter(where)包裹
但不是所有 module 都注入了 DepartmentsModule(CampusScope 依赖它)。需要在各个需要 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:
useCampushook,CampusSwitchercomponent -
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: 测试流程
- 打开
http://localhost:5173,用超管登录 - 访问
/departments→ 确认默认「主校区」存在 - 创建第二个校区「江宁校区」
- 进入「账号管理」→ 编辑某个教职工,将其分配到「江宁校区」
- 用该教职工登录 → Header 显示校区选择器,可切换
- 切换到「江宁校区」→ 学生列表/宿舍列表只显示江宁数据
- 切换到「全部校区」→ 显示两个校区数据
- 超管不选校区 → 显示全部数据(无隔离)
- 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"