1378 lines
38 KiB
Markdown
1378 lines
38 KiB
Markdown
# 多校区切换/隔离 — 实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 实现多校区树形组织结构、用户-部门绑定、全局数据查询按校区自动隔离、前端校区切换器。
|
||
|
||
**Architecture:** 新增 `departments`/`user_departments` 表 + `DepartmentsModule` + 请求级 `CampusScope` Provider,各业务实体追加 `department_id` 冗余字段,Controller 通过 `scope.filter()` 自动附加校区过滤,超管绕过。
|
||
|
||
**Tech Stack:** NestJS 11, TypeORM 0.3, React 19, Ant Design 6
|
||
|
||
## Global Constraints
|
||
|
||
- 表名使用复数形式:`departments`、`user_departments`
|
||
- Entity 使用 `@Entity('table_name')` + `@Column({ name: 'snake_case' })` 模式
|
||
- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出
|
||
- Module 必须 `imports: [TypeOrmModule.forFeature([...])]`
|
||
- Controller 所有方法 `@UseGuards(JwtAuthGuard)`
|
||
- DTO 使用 class-validator 装饰器
|
||
- 前端 axios 实例从 `api/` 导入
|
||
- 前端新增路由在 `App.tsx` 注册
|
||
- 超管 (super_admin 角色) 绕过所有校区隔离
|
||
- `department_id` 采用冗余存储策略,写入时填入,避免查询时多表 JOIN
|
||
|
||
---
|
||
|
||
### Task 1: Department + UserDepartment Entity
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/entities/department.entity.ts`
|
||
- Create: `apps/server/src/entities/user-department.entity.ts`
|
||
- Modify: `apps/server/src/entities/index.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Department` entity, `UserDepartment` entity
|
||
|
||
- [ ] **Step 1: 创建 department.entity.ts**
|
||
|
||
```typescript
|
||
// apps/server/src/entities/department.entity.ts
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
CreateDateColumn,
|
||
UpdateDateColumn,
|
||
ManyToOne,
|
||
OneToMany,
|
||
JoinColumn,
|
||
} from 'typeorm';
|
||
|
||
export enum DepartmentType {
|
||
CAMPUS = 'campus',
|
||
DEPARTMENT = 'department',
|
||
}
|
||
|
||
@Entity('departments')
|
||
export class Department {
|
||
@PrimaryGeneratedColumn()
|
||
id: number;
|
||
|
||
@Column({ length: 100 })
|
||
name: string;
|
||
|
||
@Column({ name: 'parent_id', type: 'integer', nullable: true })
|
||
parentId: number;
|
||
|
||
@ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL' })
|
||
@JoinColumn({ name: 'parent_id' })
|
||
parent: Department;
|
||
|
||
@OneToMany(() => Department, (d) => d.parent)
|
||
children: Department[];
|
||
|
||
@Column({ length: 20, default: DepartmentType.DEPARTMENT })
|
||
type: string;
|
||
|
||
@Column({ name: 'sort_order', type: 'integer', default: 0 })
|
||
sortOrder: number;
|
||
|
||
@Column({ length: 20, default: 'active' })
|
||
status: string;
|
||
|
||
@CreateDateColumn({ name: 'created_at' })
|
||
createdAt: Date;
|
||
|
||
@UpdateDateColumn({ name: 'updated_at' })
|
||
updatedAt: Date;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 user-department.entity.ts**
|
||
|
||
```typescript
|
||
// apps/server/src/entities/user-department.entity.ts
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
CreateDateColumn,
|
||
ManyToOne,
|
||
JoinColumn,
|
||
Unique,
|
||
} from 'typeorm';
|
||
import { User } from './user.entity';
|
||
import { Department } from './department.entity';
|
||
|
||
@Entity('user_departments')
|
||
@Unique(['userId', 'departmentId'])
|
||
export class UserDepartment {
|
||
@PrimaryGeneratedColumn()
|
||
id: number;
|
||
|
||
@Column({ name: 'user_id', type: 'integer' })
|
||
userId: number;
|
||
|
||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||
@JoinColumn({ name: 'user_id' })
|
||
user: User;
|
||
|
||
@Column({ name: 'department_id', type: 'integer' })
|
||
departmentId: number;
|
||
|
||
@ManyToOne(() => Department, { onDelete: 'CASCADE' })
|
||
@JoinColumn({ name: 'department_id' })
|
||
department: Department;
|
||
|
||
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||
isDefault: boolean;
|
||
|
||
@CreateDateColumn({ name: 'created_at' })
|
||
createdAt: Date;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 entities/index.ts 注册导出**
|
||
|
||
```typescript
|
||
export { Department, DepartmentType } from './department.entity';
|
||
export { UserDepartment } from './user-department.entity';
|
||
```
|
||
|
||
- [ ] **Step 4: 验证 — 启动后端检查建表**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev
|
||
```
|
||
|
||
Expected: 启动成功,`departments`、`user_departments` 表自动创建。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/entities/department.entity.ts apps/server/src/entities/user-department.entity.ts apps/server/src/entities/index.ts
|
||
git commit -m "feat: add Department and UserDepartment entities"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 现有实体追加 department_id
|
||
|
||
**Files:**
|
||
- Modify: `apps/server/src/entities/student.entity.ts`
|
||
- Modify: `apps/server/src/entities/room.entity.ts`
|
||
- Modify: `apps/server/src/entities/classroom.entity.ts`
|
||
- Modify: `apps/server/src/entities/class-schedule.entity.ts`
|
||
- Modify: `apps/server/src/entities/attendance-record.entity.ts`
|
||
- Modify: `apps/server/src/entities/room-expense.entity.ts`
|
||
- Modify: `apps/server/src/entities/personal-expense.entity.ts`
|
||
- Modify: `apps/server/src/entities/occupancy.entity.ts`
|
||
- Modify: `apps/server/src/entities/bill.entity.ts`
|
||
- Modify: `apps/server/src/entities/deposit.entity.ts`
|
||
- Modify: `apps/server/src/entities/deposit-installment.entity.ts`
|
||
- Modify: `apps/server/src/entities/classroom-rental.entity.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Department` entity from Task 1
|
||
- Produces: 所有业务实体新增 `departmentId` 字段 + `@ManyToOne` 关系
|
||
|
||
- [ ] **Step 1: 批量追加 department_id**
|
||
|
||
对每个实体,追加以下代码块(以 `student.entity.ts` 为例):
|
||
|
||
```typescript
|
||
// 在 imports 中添加:
|
||
import { Department } from './department.entity';
|
||
|
||
// 在类体中添加:
|
||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||
departmentId: number;
|
||
|
||
@ManyToOne(() => Department, { nullable: true })
|
||
@JoinColumn({ name: 'department_id' })
|
||
department: Department;
|
||
```
|
||
|
||
追加清单:
|
||
|
||
| 文件 | 说明 |
|
||
|------|------|
|
||
| `student.entity.ts` | 学生归属校区 |
|
||
| `room.entity.ts` | 宿舍归属校区 |
|
||
| `classroom.entity.ts` | 教室归属校区 |
|
||
| `class-schedule.entity.ts` | 排课归属校区(冗余) |
|
||
| `attendance-record.entity.ts` | 考勤归属校区(冗余) |
|
||
| `room-expense.entity.ts` | 宿舍费用归属校区(冗余) |
|
||
| `personal-expense.entity.ts` | 个人费用归属校区(冗余) |
|
||
| `occupancy.entity.ts` | 入住归属校区(冗余) |
|
||
| `bill.entity.ts` | 账单归属校区(冗余) |
|
||
| `deposit.entity.ts` | 押金归属校区(冗余) |
|
||
| `deposit-installment.entity.ts` | 押金分期归属校区(冗余) |
|
||
| `classroom-rental.entity.ts` | 租赁订单归属校区(冗余) |
|
||
|
||
注:`classes` 实体已有 `departmentId`,跳过。
|
||
|
||
- [ ] **Step 2: 验证 — TypeORM 自动 ALTER TABLE**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev
|
||
```
|
||
|
||
Expected: 启动成功,所有业务表新增 `department_id` 列。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/entities/
|
||
git commit -m "feat: add department_id to all business entities"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: DepartmentsModule — DTO + Service
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/departments/dto/department.dto.ts`
|
||
- Create: `apps/server/src/departments/departments.service.ts`
|
||
- Create: `apps/server/src/departments/departments.module.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Department` + `UserDepartment` entities from Task 1
|
||
- Produces: `DepartmentsService` with `findAll`, `findTree`, `create`, `update`, `remove`, `getDescendantIds`, `getUserDepartments`, `assignUser`, `removeUser`
|
||
|
||
- [ ] **Step 1: 创建 DTO**
|
||
|
||
```typescript
|
||
// apps/server/src/departments/dto/department.dto.ts
|
||
import { IsString, IsNotEmpty, IsOptional, IsInt, IsBoolean, IsEnum } from 'class-validator';
|
||
|
||
export class CreateDepartmentDto {
|
||
@IsString() @IsNotEmpty()
|
||
name: string;
|
||
|
||
@IsOptional() @IsInt()
|
||
parentId?: number;
|
||
|
||
@IsOptional() @IsString()
|
||
type?: string;
|
||
|
||
@IsOptional() @IsInt()
|
||
sortOrder?: number;
|
||
}
|
||
|
||
export class UpdateDepartmentDto {
|
||
@IsOptional() @IsString()
|
||
name?: string;
|
||
|
||
@IsOptional() @IsInt()
|
||
parentId?: number;
|
||
|
||
@IsOptional() @IsString()
|
||
type?: string;
|
||
|
||
@IsOptional() @IsInt()
|
||
sortOrder?: number;
|
||
}
|
||
|
||
export class AssignUserDto {
|
||
@IsInt()
|
||
userId: number;
|
||
|
||
@IsOptional() @IsBoolean()
|
||
isDefault?: boolean;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 Service**
|
||
|
||
```typescript
|
||
// apps/server/src/departments/departments.service.ts
|
||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository, In } from 'typeorm';
|
||
import { Department } from '../entities/department.entity';
|
||
import { UserDepartment } from '../entities/user-department.entity';
|
||
import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto';
|
||
|
||
@Injectable()
|
||
export class DepartmentsService {
|
||
constructor(
|
||
@InjectRepository(Department)
|
||
private deptRepo: Repository<Department>,
|
||
@InjectRepository(UserDepartment)
|
||
private userDeptRepo: Repository<UserDepartment>,
|
||
) {}
|
||
|
||
async findAll(): Promise<Department[]> {
|
||
return this.deptRepo.find({
|
||
where: { status: 'active' },
|
||
order: { sortOrder: 'ASC', name: 'ASC' },
|
||
});
|
||
}
|
||
|
||
async findTree(): Promise<Department[]> {
|
||
const all = await this.deptRepo.find({
|
||
where: { status: 'active' },
|
||
order: { sortOrder: 'ASC', name: 'ASC' },
|
||
relations: ['children'],
|
||
});
|
||
// 返回根节点(parent_id = null)
|
||
return all.filter((d) => d.parentId === null);
|
||
}
|
||
|
||
async findOne(id: number): Promise<Department> {
|
||
const dept = await this.deptRepo.findOne({ where: { id } });
|
||
if (!dept) throw new NotFoundException('部门不存在');
|
||
return dept;
|
||
}
|
||
|
||
async create(dto: CreateDepartmentDto): Promise<Department> {
|
||
const dept = this.deptRepo.create(dto);
|
||
return this.deptRepo.save(dept);
|
||
}
|
||
|
||
async update(id: number, dto: UpdateDepartmentDto): Promise<Department> {
|
||
const dept = await this.findOne(id);
|
||
Object.assign(dept, dto);
|
||
return this.deptRepo.save(dept);
|
||
}
|
||
|
||
async remove(id: number): Promise<void> {
|
||
// 检查是否有子部门
|
||
const children = await this.deptRepo.count({ where: { parentId: id } });
|
||
if (children > 0) throw new ConflictException('该部门下存在子部门,无法删除');
|
||
|
||
// 检查是否有关联用户
|
||
const users = await this.userDeptRepo.count({ where: { departmentId: id } });
|
||
if (users > 0) throw new ConflictException('该部门下有用户关联,无法删除');
|
||
|
||
await this.deptRepo.update(id, { status: 'archived' });
|
||
}
|
||
|
||
/** 获取部门的所有子部门 ID(递归,含自身) */
|
||
async getDescendantIds(departmentId: number): Promise<number[]> {
|
||
const ids = [departmentId];
|
||
const children = await this.deptRepo.find({
|
||
where: { parentId: departmentId, status: 'active' },
|
||
});
|
||
for (const child of children) {
|
||
const childIds = await this.getDescendantIds(child.id);
|
||
ids.push(...childIds);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/** 获取用户可访问的部门 ID 列表 */
|
||
async getUserDepartments(userId: number): Promise<number[]> {
|
||
const records = await this.userDeptRepo.find({
|
||
where: { userId },
|
||
});
|
||
return records.map((r) => r.departmentId);
|
||
}
|
||
|
||
/** 获取用户默认校区 ID */
|
||
async getUserDefaultDepartmentId(userId: number): Promise<number | null> {
|
||
const record = await this.userDeptRepo.findOne({
|
||
where: { userId, isDefault: true },
|
||
});
|
||
return record?.departmentId ?? null;
|
||
}
|
||
|
||
/** 获取部门下的用户 */
|
||
async getUsers(departmentId: number): Promise<UserDepartment[]> {
|
||
return this.userDeptRepo.find({
|
||
where: { departmentId },
|
||
relations: ['user'],
|
||
});
|
||
}
|
||
|
||
/** 为用户分配部门 */
|
||
async assignUser(departmentId: number, dto: AssignUserDto): Promise<UserDepartment> {
|
||
const record = this.userDeptRepo.create({
|
||
userId: dto.userId,
|
||
departmentId,
|
||
isDefault: dto.isDefault ?? false,
|
||
});
|
||
return this.userDeptRepo.save(record);
|
||
}
|
||
|
||
/** 移除用户-部门关联 */
|
||
async removeUser(departmentId: number, userId: number): Promise<void> {
|
||
await this.userDeptRepo.delete({ departmentId, userId });
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 Module**
|
||
|
||
```typescript
|
||
// apps/server/src/departments/departments.module.ts
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import { Department } from '../entities/department.entity';
|
||
import { UserDepartment } from '../entities/user-department.entity';
|
||
import { DepartmentsService } from './departments.service';
|
||
import { DepartmentsController } from './departments.controller';
|
||
|
||
@Module({
|
||
imports: [TypeOrmModule.forFeature([Department, UserDepartment])],
|
||
controllers: [DepartmentsController],
|
||
providers: [DepartmentsService],
|
||
exports: [DepartmentsService],
|
||
})
|
||
export class DepartmentsModule {}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/departments/
|
||
git commit -m "feat: add DepartmentsService with tree query and user assignment"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: DepartmentsController
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/departments/departments.controller.ts`
|
||
- Modify: `apps/server/src/app.module.ts` — 注册 DepartmentsModule + 在 TypeORM entities 中添加 Department/UserDepartment
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DepartmentsService` from Task 3
|
||
- Produces: REST API
|
||
|
||
- [ ] **Step 1: 创建 Controller**
|
||
|
||
```typescript
|
||
// apps/server/src/departments/departments.controller.ts
|
||
import {
|
||
Controller,
|
||
Get,
|
||
Post,
|
||
Put,
|
||
Delete,
|
||
Body,
|
||
Param,
|
||
UseGuards,
|
||
} from '@nestjs/common';
|
||
import { DepartmentsService } from './departments.service';
|
||
import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto';
|
||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||
|
||
@UseGuards(JwtAuthGuard)
|
||
@Controller('departments')
|
||
export class DepartmentsController {
|
||
constructor(private readonly service: DepartmentsService) {}
|
||
|
||
@Get()
|
||
findAll() {
|
||
return this.service.findAll();
|
||
}
|
||
|
||
@Get('tree')
|
||
findTree() {
|
||
return this.service.findTree();
|
||
}
|
||
|
||
@Get(':id')
|
||
findOne(@Param('id') id: string) {
|
||
return this.service.findOne(+id);
|
||
}
|
||
|
||
@Post()
|
||
create(@Body() dto: CreateDepartmentDto) {
|
||
return this.service.create(dto);
|
||
}
|
||
|
||
@Put(':id')
|
||
update(@Param('id') id: string, @Body() dto: UpdateDepartmentDto) {
|
||
return this.service.update(+id, dto);
|
||
}
|
||
|
||
@Delete(':id')
|
||
remove(@Param('id') id: string) {
|
||
return this.service.remove(+id);
|
||
}
|
||
|
||
@Get(':id/users')
|
||
getUsers(@Param('id') id: string) {
|
||
return this.service.getUsers(+id);
|
||
}
|
||
|
||
@Post(':id/users')
|
||
assignUser(@Param('id') id: string, @Body() dto: AssignUserDto) {
|
||
return this.service.assignUser(+id, dto);
|
||
}
|
||
|
||
@Delete(':id/users/:userId')
|
||
removeUser(@Param('id') id: string, @Param('userId') userId: string) {
|
||
return this.service.removeUser(+id, +userId);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 注册到 AppModule**
|
||
|
||
在 `apps/server/src/app.module.ts` 中:
|
||
|
||
1. 在 `TypeOrmModule.forRootAsync` 的 `allEntities` 数组中添加:
|
||
```typescript
|
||
Department,
|
||
UserDepartment,
|
||
```
|
||
|
||
2. 在 `@Module imports` 中添加:
|
||
```typescript
|
||
DepartmentsModule,
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 — 测试 API**
|
||
|
||
```bash
|
||
# 创建校区
|
||
curl -X POST http://localhost:3000/api/departments \
|
||
-H "Authorization: Bearer <token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"name":"鼓楼校区","type":"campus"}'
|
||
|
||
# 获取树
|
||
curl http://localhost:3000/api/departments/tree \
|
||
-H "Authorization: Bearer <token>"
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/departments/departments.controller.ts apps/server/src/app.module.ts
|
||
git commit -m "feat: add DepartmentsController with CRUD + user assignment"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: CampusScope + 请求级注入
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/common/campus-scope.ts`
|
||
- Modify: `apps/server/src/app.module.ts` — 注册 CampusScope provider
|
||
|
||
**Interfaces:**
|
||
- Produces: `CampusScope` 请求级 Provider,通过 `scope.filter()` 在查询条件中自动追加 `departmentId`
|
||
|
||
- [ ] **Step 1: 创建 CampusScope**
|
||
|
||
```typescript
|
||
// apps/server/src/common/campus-scope.ts
|
||
import { Injectable, Scope, Inject } from '@nestjs/common';
|
||
import { REQUEST } from '@nestjs/core';
|
||
import { In } from 'typeorm';
|
||
import { DepartmentsService } from '../departments/departments.service';
|
||
|
||
@Injectable({ scope: Scope.REQUEST })
|
||
export class CampusScope {
|
||
constructor(
|
||
@Inject(REQUEST) private req: any,
|
||
private departmentsService: DepartmentsService,
|
||
) {}
|
||
|
||
get userId(): number {
|
||
return this.req.user?.id;
|
||
}
|
||
|
||
get isSuperAdmin(): boolean {
|
||
return this.req.user?.isSuperAdmin ?? false;
|
||
}
|
||
|
||
get currentDepartmentId(): number | null {
|
||
const headerId = parseInt(this.req.headers?.['x-campus-id'] || '0', 10);
|
||
return headerId || null;
|
||
}
|
||
|
||
/** 对 TypeORM find where 条件追加校区过滤 */
|
||
async filter<T extends Record<string, any>>(where: T): Promise<T> {
|
||
// 超管 + 未选校区 → 不过滤
|
||
if (this.isSuperAdmin && !this.currentDepartmentId) {
|
||
return where;
|
||
}
|
||
|
||
const ids = await this.getEffectiveScopeIds();
|
||
if (ids.length === 0) return where;
|
||
|
||
return { ...where, departmentId: In(ids) } as any;
|
||
}
|
||
|
||
private async getEffectiveScopeIds(): Promise<number[]> {
|
||
// 如果用户选了具体校区 → 该校区 + 子部门
|
||
if (this.currentDepartmentId) {
|
||
return this.departmentsService.getDescendantIds(this.currentDepartmentId);
|
||
}
|
||
|
||
// 未选校区 → 用户关联的所有部门 + 子部门
|
||
const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);
|
||
const allIds = await Promise.all(
|
||
userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)),
|
||
);
|
||
return [...new Set(allIds.flat())];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 CampusScopeMiddleware 将 scope 注入 req**
|
||
|
||
```typescript
|
||
// apps/server/src/common/campus-scope.middleware.ts
|
||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||
import { Request, Response, NextFunction } from 'express';
|
||
import { CampusScope } from './campus-scope';
|
||
|
||
@Injectable()
|
||
export class CampusScopeMiddleware implements NestMiddleware {
|
||
constructor(private readonly scope: CampusScope) {}
|
||
|
||
use(req: Request, _res: Response, next: NextFunction) {
|
||
(req as any).campusScope = this.scope;
|
||
next();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 AppModule 中注册**
|
||
|
||
在 `apps/server/src/app.module.ts` 中:
|
||
|
||
```typescript
|
||
import { CampusScope } from './common/campus-scope';
|
||
import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||
import { MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||
|
||
// 在 providers 中添加:
|
||
CampusScope,
|
||
|
||
// AppModule 实现 NestModule:
|
||
export class AppModule implements NestModule {
|
||
configure(consumer: MiddlewareConsumer) {
|
||
consumer.apply(CampusScopeMiddleware).forRoutes('*');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/common/campus-scope.ts apps/server/src/common/campus-scope.middleware.ts apps/server/src/app.module.ts
|
||
git commit -m "feat: add CampusScope request-level provider for data isolation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 各 Service 接入 CampusScope.filter()
|
||
|
||
**Files:**
|
||
- Modify: `apps/server/src/students/students.service.ts`
|
||
- Modify: `apps/server/src/rooms/rooms.service.ts`
|
||
- Modify: `apps/server/src/occupancies/occupancies.service.ts`
|
||
- Modify: `apps/server/src/bills/bills.service.ts`
|
||
- Modify: `apps/server/src/expenses/expenses.service.ts`
|
||
- Modify: `apps/server/src/classes/classes.service.ts`
|
||
- Modify: `apps/server/src/schedules/schedules.service.ts`
|
||
- Modify: `apps/server/src/attendance/attendance.service.ts`
|
||
- Modify: `apps/server/src/deposits/deposits.service.ts`
|
||
- Modify: `apps/server/src/classrooms/classrooms.service.ts`
|
||
- Modify: `apps/server/src/classroom-rentals/classroom-rentals.service.ts`
|
||
- Modify: `apps/server/src/dashboard/dashboard.service.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CampusScope` from Task 5
|
||
- Modifies: 所有查询方法通过 `scope.filter()` 追加校区过滤
|
||
|
||
- [ ] **Step 1: 模式说明**
|
||
|
||
每个 service 的查询方法改造模式(以 `StudentsService.findAll` 为例):
|
||
|
||
```typescript
|
||
// 修改前:
|
||
async findAll(query: any) {
|
||
return this.repo.find({ where: { status: 'active' } });
|
||
}
|
||
|
||
// 修改后:
|
||
async findAll(query: any) {
|
||
const where = await this.scope.filter({ status: 'active' });
|
||
return this.repo.find({ where });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 各 Service 注入 CampusScope**
|
||
|
||
对于每个 Controller/Service 文件,需要:
|
||
1. `import { CampusScope } from '../common/campus-scope';`
|
||
2. constructor 注入: `private scope: CampusScope`
|
||
3. 所有 `find`/`findAndCount`/`createQueryBuilder` 查询用 `await this.scope.filter(where)` 包裹
|
||
|
||
但不是所有 module 都注入了 `DepartmentsModule`(CampusScope 依赖它)。需要在各个需要 CampusScope 的 module 的 imports 中添加 `DepartmentsModule`。
|
||
|
||
```typescript
|
||
// 在每个受影响的 module 中添加:
|
||
import { DepartmentsModule } from '../departments/departments.module';
|
||
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([...]),
|
||
DepartmentsModule, // 新增
|
||
],
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: 改造清单**
|
||
|
||
逐个 service 改造 `findAll` 类方法(有查询条件的方法):
|
||
|
||
| Service | 方法 | 操作 |
|
||
|---------|------|------|
|
||
| `students.service.ts` | `findAll` | `await this.scope.filter(where)` |
|
||
| `rooms.service.ts` | `findAll`, `findVisual` | 同上 |
|
||
| `occupancies.service.ts` | `findAll` | 同上 |
|
||
| `bills.service.ts` | `findAll` | 同上 |
|
||
| `expenses.service.ts` | 所有查询 | 同上 |
|
||
| `classes.service.ts` | `findAll` | 同上 |
|
||
| `schedules.service.ts` | `findAll`, `findWeekly` | 同上 |
|
||
| `attendance.service.ts` | 所有查询 | 同上 |
|
||
| `deposits.service.ts` | 所有查询 | 同上 |
|
||
| `classrooms.service.ts` | `findAll` | 同上 |
|
||
| `classroom-rentals.service.ts` | `findAll` | 同上 |
|
||
| `dashboard.service.ts` | `getStats` | 同上 |
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/
|
||
git commit -m "feat: integrate CampusScope.filter() into all business services"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 写操作时填充 department_id
|
||
|
||
**Files:**
|
||
- Modify: 各 Service 的 create/update 方法
|
||
|
||
**Interfaces:**
|
||
- Modifies: 创建记录时自动填充 `departmentId`(从关联实体或前端输入获取)
|
||
|
||
- [ ] **Step 1: 创建 Room 时填充 department_id**
|
||
|
||
```typescript
|
||
// rooms.service.ts create()
|
||
async create(dto: CreateRoomDto) {
|
||
const room = this.repo.create({
|
||
...dto,
|
||
departmentId: dto.departmentId, // 前端传入(由校区选择器当前选中值决定)
|
||
});
|
||
return this.repo.save(room);
|
||
}
|
||
```
|
||
|
||
`rooms.dto.ts` 需新增字段:
|
||
```typescript
|
||
@IsOptional() @IsInt()
|
||
departmentId?: number;
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 Student 时从关联 Class 获取 department_id**
|
||
|
||
```typescript
|
||
// students.service.ts create()
|
||
async create(dto: CreateStudentDto) {
|
||
let departmentId = dto.departmentId;
|
||
if (!departmentId && dto.classId) {
|
||
const cls = await this.classesRepo.findOne({ where: { id: dto.classId } });
|
||
departmentId = cls?.departmentId || null;
|
||
}
|
||
const student = this.repo.create({ ...dto, departmentId });
|
||
return this.repo.save(student);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 其他实体类似处理**
|
||
|
||
| 实体创建时 | department_id 来源 |
|
||
|-----------|-------------------|
|
||
| Occupancy | 从关联的 Room.departmentId |
|
||
| Bill | 从关联的 Student.departmentId |
|
||
| PersonalExpense | 从关联的 Student.departmentId |
|
||
| RoomExpense | 从关联的 Room.departmentId |
|
||
| AttendanceRecord | 从关联的 Student.departmentId |
|
||
| Deposit | 从关联的 Student.departmentId |
|
||
| DepositInstallment | 从关联的 Deposit.departmentId |
|
||
| ClassSchedule | 从关联的 Class.departmentId 或前端传入 |
|
||
| ClassroomRental | 从关联的 Classroom.departmentId |
|
||
| Classroom | 前端传入 |
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/
|
||
git commit -m "feat: auto-populate department_id on entity creation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: JWT Payload 扩展 + 登录注入 isSuperAdmin
|
||
|
||
**Files:**
|
||
- Modify: `apps/server/src/auth/auth.service.ts`
|
||
- Modify: `apps/server/src/auth/strategies/jwt.strategy.ts`
|
||
|
||
**Interfaces:**
|
||
- Modifies: JWT payload 增加 `isSuperAdmin` 标记
|
||
|
||
- [ ] **Step 1: auth.service.ts — login() 注入 isSuperAdmin**
|
||
|
||
在 `login()` 方法的 payload 构造处:
|
||
|
||
```typescript
|
||
// auth.service.ts
|
||
const isSuperAdmin = user.roles?.some(r => r.name === 'super_admin') ?? false;
|
||
const payload = {
|
||
sub: user.id,
|
||
username: user.username,
|
||
permissions,
|
||
isSuperAdmin,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: jwt.strategy.ts — validate() 透传 isSuperAdmin**
|
||
|
||
```typescript
|
||
// jwt.strategy.ts validate()
|
||
async validate(payload: any) {
|
||
return {
|
||
id: payload.sub,
|
||
username: payload.username,
|
||
permissions: payload.permissions || [],
|
||
isSuperAdmin: payload.isSuperAdmin || false,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/auth/
|
||
git commit -m "feat: add isSuperAdmin to JWT payload for campus scope bypass"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 数据回填 — 默认校区 + 历史数据迁移
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/departments/seed.service.ts`(或直接用 migration script)
|
||
|
||
- [ ] **Step 1: 创建 seed 脚本**
|
||
|
||
```typescript
|
||
// apps/server/src/departments/seed.ts
|
||
// 在 NestJS bootstrap 后执行(或作为独立脚本运行)
|
||
import { DataSource } from 'typeorm';
|
||
|
||
export async function seedDefaultCampus(dataSource: DataSource) {
|
||
const deptRepo = dataSource.getRepository('departments');
|
||
const userDeptRepo = dataSource.getRepository('user_departments');
|
||
|
||
// 1. 检查是否已有校区数据
|
||
const existing = await deptRepo.count();
|
||
if (existing > 0) {
|
||
console.log('Departments already exist, skipping seed');
|
||
return;
|
||
}
|
||
|
||
// 2. 创建默认校区
|
||
const campus = await deptRepo.save({
|
||
name: '主校区',
|
||
type: 'campus',
|
||
sortOrder: 0,
|
||
});
|
||
|
||
// 3. 回填所有业务数据的 department_id
|
||
const tables = [
|
||
'students', 'rooms', 'classrooms', 'class_schedules',
|
||
'attendance_records', 'room_expenses', 'personal_expenses',
|
||
'occupancies', 'bills', 'deposits', 'deposit_installments',
|
||
'classroom_rentals',
|
||
];
|
||
|
||
for (const table of tables) {
|
||
await dataSource.query(
|
||
`UPDATE ${table} SET department_id = ? WHERE department_id IS NULL`,
|
||
[campus.id],
|
||
);
|
||
}
|
||
|
||
// 4. 所有现有用户关联到默认校区
|
||
const users = await dataSource.query('SELECT id FROM users');
|
||
for (const user of users) {
|
||
await userDeptRepo.save({
|
||
userId: user.id,
|
||
departmentId: campus.id,
|
||
isDefault: true,
|
||
});
|
||
}
|
||
|
||
console.log('Seed complete: default campus created, data backfilled');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 main.ts 中注册 seed**
|
||
|
||
```typescript
|
||
// apps/server/src/main.ts
|
||
// 在 app.listen() 之前:
|
||
const dataSource = app.get(DataSource);
|
||
await seedDefaultCampus(dataSource);
|
||
```
|
||
|
||
- [ ] **Step 3: 运行验证**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev
|
||
```
|
||
|
||
Expected: 启动日志显示 "Seed complete: default campus created, data backfilled"。
|
||
|
||
验证:查询 `SELECT COUNT(*) FROM students WHERE department_id IS NULL` 应为 0。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/departments/seed.ts apps/server/src/main.ts
|
||
git commit -m "feat: add default campus seed with historical data backfill"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 前端 — useCampus Hook + CampusSwitcher 组件
|
||
|
||
**Files:**
|
||
- Create: `apps/admin/src/hooks/useCampus.ts`
|
||
- Create: `apps/admin/src/components/CampusSwitcher.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GET /api/departments/tree` + `GET /api/departments` (for flat list)
|
||
- Produces: `useCampus` hook, `CampusSwitcher` component
|
||
|
||
- [ ] **Step 1: 创建 useCampus hook**
|
||
|
||
```typescript
|
||
// apps/admin/src/hooks/useCampus.ts
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import api from '../api';
|
||
|
||
interface Department {
|
||
id: number;
|
||
name: string;
|
||
type: string;
|
||
parentId: number | null;
|
||
}
|
||
|
||
export function useCampus() {
|
||
const [campuses, setCampuses] = useState<Department[]>([]);
|
||
const [currentId, setCurrentId] = useState<string>(
|
||
() => localStorage.getItem('currentCampusId') || ''
|
||
);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const fetchCampuses = useCallback(async () => {
|
||
try {
|
||
// 只取校区级部门(type=campus)
|
||
const data = await api.get('/departments') as unknown as Department[];
|
||
const campusList = data.filter((d) => d.type === 'campus');
|
||
setCampuses(campusList);
|
||
|
||
// 如果没有选中校区,选第一个
|
||
if (!currentId && campusList.length > 0) {
|
||
setCurrentId(String(campusList[0].id));
|
||
localStorage.setItem('currentCampusId', String(campusList[0].id));
|
||
}
|
||
} catch {
|
||
// 静默失败
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [currentId]);
|
||
|
||
useEffect(() => {
|
||
fetchCampuses();
|
||
}, []);
|
||
|
||
const switchCampus = useCallback((id: string) => {
|
||
setCurrentId(id);
|
||
localStorage.setItem('currentCampusId', id);
|
||
// 触发全局数据刷新
|
||
window.dispatchEvent(new CustomEvent('campus-changed', { detail: id }));
|
||
}, []);
|
||
|
||
return { campuses, currentId, switchCampus, loading };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 CampusSwitcher 组件**
|
||
|
||
```tsx
|
||
// apps/admin/src/components/CampusSwitcher.tsx
|
||
import React from 'react';
|
||
import { Select, Typography } from 'antd';
|
||
import { EnvironmentOutlined } from '@ant-design/icons';
|
||
import { useCampus } from '../hooks/useCampus';
|
||
|
||
const CampusSwitcher: React.FC = () => {
|
||
const { campuses, currentId, switchCampus, loading } = useCampus();
|
||
|
||
// 只有一个校区 → 纯文本展示
|
||
if (campuses.length <= 1) {
|
||
return (
|
||
<Typography.Text style={{ color: '#fff', marginRight: 24 }}>
|
||
<EnvironmentOutlined style={{ marginRight: 4 }} />
|
||
{campuses[0]?.name || '主校区'}
|
||
</Typography.Text>
|
||
);
|
||
}
|
||
|
||
const options = [
|
||
...campuses.map((c) => ({ value: String(c.id), label: c.name })),
|
||
{ value: '', label: '全部校区' },
|
||
];
|
||
|
||
return (
|
||
<Select
|
||
value={currentId || undefined}
|
||
onChange={switchCampus}
|
||
options={options}
|
||
loading={loading}
|
||
style={{ minWidth: 140, marginRight: 24 }}
|
||
variant="borderless"
|
||
popupMatchSelectWidth={false}
|
||
/>
|
||
);
|
||
};
|
||
|
||
export default CampusSwitcher;
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src/hooks/useCampus.ts apps/admin/src/components/CampusSwitcher.tsx
|
||
git commit -m "feat: add useCampus hook and CampusSwitcher component"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: 前端 — MainLayout 集成校区选择器 + API interceptor
|
||
|
||
**Files:**
|
||
- Modify: `apps/admin/src/layouts/MainLayout.tsx`
|
||
- Modify: `apps/admin/src/api/index.ts`
|
||
|
||
- [ ] **Step 1: MainLayout 添加 CampusSwitcher**
|
||
|
||
在 `MainLayout.tsx` 的 Header 中,Logo 旁边添加:
|
||
|
||
```tsx
|
||
import CampusSwitcher from '../components/CampusSwitcher';
|
||
|
||
// 在 Header 内 Logo 区域后:
|
||
<CampusSwitcher />
|
||
```
|
||
|
||
- [ ] **Step 2: API interceptor 注入 X-Campus-Id**
|
||
|
||
```typescript
|
||
// apps/admin/src/api/index.ts
|
||
// 在已有的 request interceptor 中添加:
|
||
api.interceptors.request.use((config) => {
|
||
// ... 已有的 token 注入 ...
|
||
|
||
const campusId = localStorage.getItem('currentCampusId');
|
||
if (campusId) {
|
||
config.headers['X-Campus-Id'] = campusId;
|
||
}
|
||
return config;
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src/layouts/MainLayout.tsx apps/admin/src/api/index.ts
|
||
git commit -m "feat: integrate CampusSwitcher into header and API interceptor"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: 前端 — 部门管理页
|
||
|
||
**Files:**
|
||
- Create: `apps/admin/src/pages/Departments/index.tsx`
|
||
- Modify: `apps/admin/src/App.tsx` — 注册路由
|
||
|
||
- [ ] **Step 1: 创建 Departments 管理页**
|
||
|
||
```tsx
|
||
// apps/admin/src/pages/Departments/index.tsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Tree, Card, Button, Modal, Form, Input, Select, InputNumber,
|
||
Space, Table, Popconfirm, message, Row, Col,
|
||
} from 'antd';
|
||
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||
import api from '../../api';
|
||
|
||
interface Department {
|
||
id: number;
|
||
name: string;
|
||
parentId: number | null;
|
||
type: string;
|
||
sortOrder: number;
|
||
children?: Department[];
|
||
}
|
||
|
||
const DepartmentsPage: React.FC = () => {
|
||
const [tree, setTree] = useState<Department[]>([]);
|
||
const [selected, setSelected] = useState<Department | null>(null);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<Department | null>(null);
|
||
const [users, setUsers] = useState<any[]>([]);
|
||
const [form] = Form.useForm();
|
||
|
||
const fetchTree = async () => {
|
||
try {
|
||
const data = await api.get('/departments/tree') as unknown as Department[];
|
||
setTree(data);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
useEffect(() => { fetchTree(); }, []);
|
||
|
||
const handleSelect = async (keys: React.Key[]) => {
|
||
if (keys.length === 0) return;
|
||
try {
|
||
const dept = await api.get(`/departments/${keys[0]}`) as unknown as Department;
|
||
setSelected(dept);
|
||
const userData = await api.get(`/departments/${keys[0]}/users`) as unknown as any[];
|
||
setUsers(userData);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
try {
|
||
if (editing) {
|
||
await api.put(`/departments/${editing.id}`, values);
|
||
message.success('更新成功');
|
||
} else {
|
||
await api.post('/departments', values);
|
||
message.success('创建成功');
|
||
}
|
||
setModalOpen(false);
|
||
fetchTree();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await api.delete(`/departments/${id}`);
|
||
message.success('已删除');
|
||
setSelected(null);
|
||
fetchTree();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '删除失败');
|
||
}
|
||
};
|
||
|
||
const openCreate = (parentId?: number) => {
|
||
setEditing(null);
|
||
form.resetFields();
|
||
form.setFieldsValue({ parentId: parentId ?? null, type: 'department', sortOrder: 0 });
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const openEdit = () => {
|
||
if (!selected) return;
|
||
setEditing(selected);
|
||
form.setFieldsValue(selected);
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const treeData = tree.map((node) => ({
|
||
title: `${node.name} (${node.type === 'campus' ? '校区' : '部门'})`,
|
||
key: node.id,
|
||
children: node.children?.map((child) => ({
|
||
title: `${child.name} (${child.type === 'campus' ? '校区' : '部门'})`,
|
||
key: child.id,
|
||
})),
|
||
}));
|
||
|
||
return (
|
||
<Row gutter={24}>
|
||
<Col span={8}>
|
||
<Card
|
||
title="组织架构"
|
||
extra={
|
||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => openCreate()}>
|
||
新增校区
|
||
</Button>
|
||
}
|
||
>
|
||
<Tree
|
||
treeData={treeData}
|
||
onSelect={handleSelect}
|
||
style={{ minHeight: 400 }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={16}>
|
||
{selected ? (
|
||
<Card
|
||
title={selected.name}
|
||
extra={
|
||
<Space>
|
||
<Button size="small" icon={<PlusOutlined />} onClick={() => openCreate(selected.id)}>
|
||
添加子部门
|
||
</Button>
|
||
<Button size="small" icon={<EditOutlined />} onClick={openEdit}>
|
||
编辑
|
||
</Button>
|
||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(selected.id)}>
|
||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
}
|
||
>
|
||
<p>类型:{selected.type === 'campus' ? '校区' : '部门'}</p>
|
||
<p>排序:{selected.sortOrder}</p>
|
||
<h4>部门成员</h4>
|
||
<Table
|
||
dataSource={users}
|
||
rowKey="id"
|
||
columns={[
|
||
{ title: '用户名', dataIndex: ['user', 'username'] },
|
||
{ title: '姓名', dataIndex: ['user', 'name'] },
|
||
{ title: '默认校区', dataIndex: 'isDefault', render: (v: boolean) => v ? '是' : '否' },
|
||
]}
|
||
size="small"
|
||
/>
|
||
</Card>
|
||
) : (
|
||
<Card>
|
||
<div style={{ textAlign: 'center', color: '#999', padding: 40 }}>
|
||
请从左侧选择一个部门
|
||
</div>
|
||
</Card>
|
||
)}
|
||
</Col>
|
||
|
||
<Modal
|
||
title={editing ? '编辑部门' : '新增部门'}
|
||
open={modalOpen}
|
||
onOk={handleSave}
|
||
onCancel={() => setModalOpen(false)}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="parentId" label="上级部门">
|
||
<Select
|
||
allowClear
|
||
options={[
|
||
...tree.map((d) => ({ value: d.id, label: d.name })),
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="type" label="类型">
|
||
<Select
|
||
options={[
|
||
{ value: 'campus', label: '校区' },
|
||
{ value: 'department', label: '部门' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="sortOrder" label="排序">
|
||
<InputNumber min={0} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</Row>
|
||
);
|
||
};
|
||
|
||
export default DepartmentsPage;
|
||
```
|
||
|
||
- [ ] **Step 2: 在 App.tsx 注册路由**
|
||
|
||
```tsx
|
||
import DepartmentsPage from './pages/Departments';
|
||
|
||
// 在 Routes 内添加:
|
||
<Route path="/departments" element={
|
||
<PrivateRoute>
|
||
<MainLayout />
|
||
</PrivateRoute>
|
||
}>
|
||
<Route index element={<DepartmentsPage />} />
|
||
</Route>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src/pages/Departments/index.tsx apps/admin/src/App.tsx
|
||
git commit -m "feat: add Departments management page with tree + user list"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: 验证 + 端到端测试
|
||
|
||
- [ ] **Step 1: 启动完整环境**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev &
|
||
cd apps/admin && npm run dev &
|
||
```
|
||
|
||
- [ ] **Step 2: 测试流程**
|
||
|
||
1. 打开 `http://localhost:5173`,用超管登录
|
||
2. 访问 `/departments` → 确认默认「主校区」存在
|
||
3. 创建第二个校区「江宁校区」
|
||
4. 进入「账号管理」→ 编辑某个教职工,将其分配到「江宁校区」
|
||
5. 用该教职工登录 → Header 显示校区选择器,可切换
|
||
6. 切换到「江宁校区」→ 学生列表/宿舍列表只显示江宁数据
|
||
7. 切换到「全部校区」→ 显示两个校区数据
|
||
8. 超管不选校区 → 显示全部数据(无隔离)
|
||
|
||
- [ ] **Step 3: 数据隔离验证**
|
||
|
||
SQL 验证:
|
||
```sql
|
||
-- 确认历史数据已回填
|
||
SELECT COUNT(*) FROM students WHERE department_id IS NULL; -- 期望 0
|
||
SELECT COUNT(*) FROM rooms WHERE department_id IS NULL; -- 期望 0
|
||
|
||
-- 确认新创建实体自动填充
|
||
INSERT INTO students (...) VALUES (...);
|
||
-- 应自动填 department_id
|
||
```
|
||
|
||
- [ ] **Step 4: Commit (如有调整)**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix: campus isolation tweaks and seed adjustments"
|
||
```
|