fix: resolve all admin typecheck errors (typed api wrapper, unused imports, missing hooks, PermissionButton children)

This commit is contained in:
2026-07-06 01:02:26 +08:00
parent 4a48a65a48
commit 6b0a21d266
22 changed files with 4051 additions and 273 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,340 @@
# 多校区切换/隔离 — 设计规格
> 版本v1.0
> 日期2026-07-05
> 基于 PRD`PRD-恭学教育学生管理系统.md` §23.7多校区隔离确认需要Department.type=campus 预留)+
> §1.2(角色数据范围:超管全部 / 教职工指定部门+子部门 / 班主任本班 / 学生本人)
## 1. 目标
为系统引入多校区Campus概念实现
- 校区树形组织结构(校区 → 子部门 → 班级)
- 用户-部门绑定 + 默认校区
- 全局数据查询按校区自动隔离
- 前端校区切换器(支持单校区 / 全部校区视图)
## 2. 数据模型
### 2.1 部门表 `departments`
```sql
departments
├── id INTEGER PK AUTOINCREMENT
├── name VARCHAR(100) NOT NULL -- 部门名称,如 "鼓楼校区"
├── parent_id INTEGER NULLABLE FK self -- 上级部门NULL = 顶层校区
├── type VARCHAR(20) DEFAULT 'department'
-- 'campus' = 校区顶层parent_id=NULL
-- 'department' = 子部门(教学部、后勤部等)
├── sort_order INTEGER DEFAULT 0
├── status VARCHAR(20) DEFAULT 'active' -- active / archived
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
├── updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
```
### 2.2 用户-部门关联 `user_departments`
```sql
user_departments
├── id INTEGER PK AUTOINCREMENT
├── user_id INTEGER NOT NULL FK users.id
├── department_id INTEGER NOT NULL FK departments.id
├── is_default BOOLEAN DEFAULT false
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
UNIQUE(user_id, department_id)
```
超管不在此表有记录 → 默认全部数据可见。学生不发此关联,通过 `students.department_id` 表所属部门。
### 2.3 现有实体追加 `department_id`
采用**冗余存储**策略(写入时填入,避免查询时多表 JOIN
| 实体 | 操作 | 说明 |
|------|:---:|------|
| `students` | 新增 | 学生所属部门 |
| `classes` | 保留 | 已有 `department_id` 字段 |
| `rooms` | 新增 | 宿舍归属校区 |
| `classrooms` | 新增 | 教室归属校区 |
| `class_schedules` | 新增 | 冗余加速(也可从 class→department 间接获取) |
| `attendance_records` | 新增 | 冗余加速(也可从 student→department 间接获取) |
| `room_expenses` | 新增 | 冗余加速(也可从 room→department 间接获取) |
| `personal_expenses` | 新增 | 冗余加速(也可从 student→department 间接获取) |
| `occupancies` | 新增 | 冗余加速(也可从 room→department 间接获取) |
| `bills` | 新增 | 冗余加速(也可从 student→department 间接获取) |
| `deposits` | 新增 | 冗余加速(也可从 student→department 间接获取) |
| `deposit_installments` | 新增 | 冗余加速 |
| `classroom_rentals` | 新增 | 冗余加速(也可从 classroom→department 间接获取) |
**全局共享不隔离**`users``roles``permissions``tenants``operation_logs``notifications``sync_logs``sync_states``ding_attendance_raw``expense_types`
## 3. 后端隔离机制
### 3.1 JWT Payload 扩展
```typescript
// 登录时注入
const payload = {
sub: user.id,
username: user.username,
permissions,
isSuperAdmin: user.roles?.some(r => r.name === 'super_admin'),
// 不再注入 departmentIds改为请求级 CampusScope 实时查询
};
```
不将 `departmentIds` 写入 JWT避免校区分配变更后需重新登录。
### 3.2 CampusScope请求级 Provider
```typescript
// apps/server/src/common/campus-scope.ts
@Injectable({ scope: Scope.REQUEST })
export class CampusScope {
private _departmentIds: number[] | null = null;
currentDepartmentId: number | null;
constructor(
@Inject(REQUEST) private req: any,
private departmentsService: DepartmentsService,
) {
this.currentDepartmentId = parseInt(
req.headers['x-campus-id'] || '0'
) || null;
}
get isSuperAdmin(): boolean {
return this.req.user?.isSuperAdmin ?? false;
}
/** 获取当前用户可访问的所有部门 ID含子部门 */
async getDepartmentIds(): Promise<number[]> {
if (this._departmentIds) return this._departmentIds;
const userDepts = await this.departmentsService.getUserDepartments(
this.req.user.id
);
this._departmentIds = userDepts;
return this._departmentIds;
}
/** 对 TypeORM find 条件追加校区过滤 */
async filter<T extends Record<string, any>>(where: T): Promise<T> {
if (this.isSuperAdmin && !this.currentDepartmentId) return where;
const ids = await this.getEffectiveScopeIds();
return { ...where, departmentId: In(ids) } as any;
}
private async getEffectiveScopeIds(): Promise<number[]> {
// 选择了具体校区 → 该校区 + 所有子部门
// 未选 → 用户所有可访问部门 + 子部门
const baseId = this.currentDepartmentId;
if (baseId) {
return this.departmentsService.getDescendantIds(baseId);
}
const allIds = await this.getDepartmentIds();
const expanded = await Promise.all(
allIds.map(id => this.departmentsService.getDescendantIds(id))
);
return [...new Set(expanded.flat())];
}
}
```
### 3.3 Controller 使用模式
```typescript
@Get()
async findAll(@Req() req: Request) {
const scope = req.campusScope; // 由 middleware 注入
const where = await scope.filter({ status: 'active' });
return this.repo.find({ where, order: { createdAt: 'DESC' } });
}
```
超管不传 `X-Campus-Id``filter()` 原样返回 → 不做隔离。
### 3.4 校区选择 Header
前端 axios interceptor 注入:
```typescript
api.interceptors.request.use((config) => {
const campusId = localStorage.getItem('currentCampusId');
if (campusId) {
config.headers['X-Campus-Id'] = campusId;
}
return config;
});
```
### 3.5 部门管理 CRUD
| 方法 | 路径 | 认证 | 说明 |
|------|------|:---:|------|
| GET | `/departments` | JWT | 部门列表(树形结构,按 sort_order + name |
| GET | `/departments/:id` | JWT | 部门详情 |
| POST | `/departments` | JWT | 创建部门(指定 parent_id + type |
| PUT | `/departments/:id` | JWT | 编辑部门 |
| DELETE | `/departments/:id` | JWT | 删除部门(检查无子部门+无关联用户) |
| GET | `/departments/:id/users` | JWT | 部门下关联的用户列表 |
| POST | `/departments/:id/users` | JWT | 为用户分配部门 `{ userId, isDefault? }` |
| DELETE | `/departments/:id/users/:userId` | JWT | 移除用户-部门关联 |
| GET | `/departments/tree` | JWT | 树形数据(前端级联选择器用) |
### 3.6 写操作时 department_id 填充
新建宿舍时:
```typescript
async create(dto: CreateRoomDto) {
// department_id 由前端传入(校区选择器当前选中值)
return this.repo.save({ ...dto, departmentId: dto.departmentId });
}
```
新建学生时关联班级的 department
```typescript
async create(dto: CreateStudentDto) {
const cls = await this.classesRepo.findOne({ where: { id: dto.classId } });
return this.studentRepo.save({
...dto,
departmentId: cls?.departmentId,
});
}
```
## 4. 前端
### 4.1 校区选择器 `CampusSwitcher`
Header 左侧Logo 旁边:
```
[ 恭学教育 ] [ 鼓楼校区 ▾ ]
```
- `Select` 组件,列出当前用户可访问的校区(从 JWT payload 或 `/departments` API 获取)
- 切换时:`localStorage.setItem('currentCampusId', id)` + 刷新所有页面数据
- 多校区权限用户底部显示「全部校区」选项value 为空字符串)
- 仅一个校区 → 纯文本展示,不可切换
- 默认选中 `localStorage.getItem('currentCampusId')` 或用户默认校区
### 4.2 `useCampus` Hook
```typescript
function useCampus() {
const [campuses, setCampuses] = useState<Department[]>([]);
const [currentId, setCurrentId] = useState<string>(
() => localStorage.getItem('currentCampusId') || ''
);
const switchCampus = (id: string) => {
setCurrentId(id);
localStorage.setItem('currentCampusId', id);
// 触发全局数据刷新(通过 event 或 context
window.dispatchEvent(new CustomEvent('campus-changed', { detail: id }));
};
return { campuses, currentId, switchCampus };
}
```
### 4.3 部门管理页面 `Departments/index.tsx`
- 左侧 `Tree` 组件展示部门树
- 点击节点 → 右侧表单编辑部门信息
- 右键/操作按钮:添加子部门、编辑、删除
- 「部门成员」Tab用户列表 + 分配/移除
### 4.4 数据面板适配
选中「全部校区」时:
- 统计卡片数值为各校区汇总
- 图表按校区分组(图例标注校区名)
- 不选全部校区 → 仅显示当前校区数据
## 5. 文件结构
```
apps/server/src/
├── entities/
│ ├── department.entity.ts 🆕
│ ├── user-department.entity.ts 🆕
│ ├── student.entity.ts ✏️ +department_id
│ ├── room.entity.ts ✏️ +department_id
│ ├── classroom.entity.ts ✏️ +department_id
│ ├── class-schedule.entity.ts ✏️ +department_id
│ ├── attendance-record.entity.ts ✏️ +department_id
│ ├── room-expense.entity.ts ✏️ +department_id
│ ├── personal-expense.entity.ts ✏️ +department_id
│ ├── occupancy.entity.ts ✏️ +department_id
│ ├── bill.entity.ts ✏️ +department_id
│ ├── deposit.entity.ts ✏️ +department_id
│ ├── deposit-installment.entity.ts ✏️ +department_id
│ ├── classroom-rental.entity.ts ✏️ +department_id
│ └── index.ts ✏️
├── departments/ 🆕
│ ├── departments.module.ts
│ ├── departments.controller.ts
│ ├── departments.service.ts
│ └── dto/
│ └── department.dto.ts
├── common/
│ └── campus-scope.ts 🆕
├── auth/
│ ├── auth.service.ts ✏️ 登录注入 isSuperAdmin
│ └── strategies/jwt.strategy.ts ✏️ payload 扩展
├── students/
│ └── students.service.ts ✏️ create 时填充 department_id
├── rooms/
│ └── rooms.service.ts ✏️ create 时填充 department_id
├── ...(各 service 使用 scope.filter()
└── app.module.ts ✏️ 注册 DepartmentsModule + CampusScope
apps/admin/src/
├── pages/
│ └── Departments/
│ └── index.tsx 🆕 部门管理页
├── components/
│ └── CampusSwitcher.tsx 🆕
├── api/index.ts ✏️ interceptor 加 X-Campus-Id
├── hooks/
│ └── useCampus.ts 🆕
├── layouts/
│ └── MainLayout.tsx ✏️ 挂载 CampusSwitcher
└── App.tsx ✏️ 注册 /departments 路由
```
## 6. 数据库迁移
### 新增表
`departments``user_departments` — TypeORM `synchronize: true` 自动建表。
### 现有表 ALTER
```sql
ALTER TABLE students ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE rooms ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE classrooms ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE class_schedules ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE attendance_records ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE room_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE personal_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE occupancies ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE bills ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE deposits ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE deposit_installments ADD COLUMN department_id INTEGER REFERENCES departments(id);
ALTER TABLE classroom_rentals ADD COLUMN department_id INTEGER REFERENCES departments(id);
```
### 数据回填
1. 创建默认校区 "主校区"`departments` type=campus
2. 所有现有数据的 `department_id` 回填为默认校区 ID
3. 现有用户全部关联到默认校区(`user_departments`
## 7. 钉钉同步集成
钉钉组织架构拉取已有能力PRD §19.1),同步时将钉钉部门树映射到 `departments` 表:
- 根部门 → `type = 'campus'`
- 子部门 → `type = 'department'`
- 同步时维护 `parent_id` 树结构

View File

@@ -0,0 +1,233 @@
# 站内信通知中心 — 设计规格
> 版本v1.0
> 日期2026-07-05
> 基于 PRD`PRD-恭学教育学生管理系统.md` §23.7(通知中心确认需要) + §12.3(账单通知推送 P2
## 1. 目标
为系统全部角色(超管、教职工、班主任、学生)提供统一的站内信通知中心,支撑以下业务场景的实时通知,同时预留钉钉/企微外发扩展点。
## 2. 通知场景
| 场景 | 触发方 | 通知类型 | 接收方 |
|------|--------|----------|--------|
| 账单生成 | 系统/管理员 | `bill_generated` | 学生 + 财务 |
| 账单确认/已付 | 管理员 | `bill_paid` | 学生 + 宿管 |
| 入住登记 | 宿管 | `check_in` | 宿管 + 学生 |
| 退宿 | 宿管 | `check_out` | 宿管 + 学生 |
| 押金催缴 | 财务 | `deposit_due` | 学生 + 财务 |
| 押金退还 | 财务 | `deposit_refunded` | 学生 + 财务 |
| 班级学员增减 | 教务 | `class_change` | 班主任 |
| 班级教师调整 | 教务 | `class_change` | 相关教师 |
| 排课冲突 | 系统检测 | `schedule_conflict` | 教务 |
| 系统公告 | 管理员手动 | `announcement` | 全员/指定角色 |
## 3. 技术方案
**SSE (Server-Sent Events) 推送 + 轮询兜底。**
- NestJS 原生 `@Sse()` + RxJS `Observable`
- 前端 `EventSource` 建立长连接,断开时自动重连
- 重连间隙兜底轮询 `GET /notifications/unread-count`60s 间隔)
- 钉钉/企微外发通过 EventEmitter2 异步解耦
## 4. 数据模型
```sql
notifications
├── id INTEGER PK AUTOINCREMENT
├── recipient_id INTEGER NOT NULL -- FK → users.id
├── type VARCHAR(30) NOT NULL -- bill_generated | bill_paid | check_in | check_out |
-- deposit_due | deposit_refunded | class_change |
-- schedule_conflict | announcement
├── title VARCHAR(200) NOT NULL -- 通知标题
├── content TEXT -- 通知正文(支持模板变量)
├── link VARCHAR(500) NULLABLE -- 点击跳转路径,如 /bills/123
├── is_read BOOLEAN DEFAULT false
├── read_at DATETIME NULLABLE
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
```
设计决策:
- **一通知一接收方** — 同一事件对 N 个用户各建一条记录,避免 `is_read` 共享状态。
- **无软删除** — 通知不可删除(保留审计痕迹),支持"全部已读"。
- **cursor-based 分页** — `?after=<id>&limit=20`,适合实时追加场景。
## 5. 后端模块
### 5.1 文件结构
```
apps/server/src/
├── entities/
│ └── notification.entity.ts 🆕
├── notifications/ 🆕
│ ├── notifications.module.ts
│ ├── notifications.controller.ts
│ ├── notifications.service.ts
│ └── dto/
│ └── notification.dto.ts
└── app.module.ts ✏️ 注册 NotificationsModule
```
### 5.2 API
| 方法 | 路径 | 认证 | 说明 |
|------|------|:---:|------|
| GET | `/notifications` | JWT | 当前用户通知列表cursor 分页,`?after=&limit=20` |
| GET | `/notifications/unread-count` | JWT | `{ count: number }` |
| GET | `/notifications/stream` | JWT | SSE 端点,`text/event-stream` |
| PUT | `/notifications/:id/read` | JWT | 标记单条已读 |
| PUT | `/notifications/read-all` | JWT | 当前用户全部已读 |
### 5.3 Service 接口
```typescript
class NotificationsService {
create(dto: CreateNotificationDto): Promise<Notification>;
findByUser(userId: number, after?: number, limit?: number): Promise<Notification[]>;
getUnreadCount(userId: number): Promise<number>;
markRead(id: number, userId: number): Promise<void>;
markAllRead(userId: number): Promise<void>;
subscribe(userId: number): Observable<Notification>; // SSE
}
```
### 5.4 SSE 实现要点
- Controller 使用 `@Sse('stream')` + `@Req()` 获取 `req.user.id`
- Service 内部维护 `Map<userId, Subject<Notification>>`
- `create()` 方法写入 DB 后 → `subject.next(notification)` 推送给订阅者
- 用户断开连接时清理 Subject
### 5.5 业务模块集成模式
各业务 Controller 写操作完成后调用:
```typescript
this.notificationsService.create({
recipientIds: [studentUserId, financeUserIds],
type: 'bill_generated',
title: '账单已生成',
content: `您的 ${periodLabel} 账单已生成,总额 ¥${totalAmount}`,
link: `/bills/${billId}`,
});
```
钉钉/企微外发通过 `EventEmitter2` 解耦:
```typescript
this.eventEmitter.emit('notification.created', notification);
```
## 6. 前端
### 6.1 文件结构
```
apps/admin/src/
├── pages/
│ └── Notifications/
│ └── index.tsx 🆕 通知全屏页
├── components/
│ └── NotificationBell.tsx 🆕 Header 铃铛组件
├── hooks/
│ └── useNotifications.ts 🆕 SSE 连接 + 未读计数
└── layouts/
└── MainLayout.tsx ✏️ 挂载 NotificationBell + SSE hook
```
### 6.2 Header 铃铛
- `Badge` 组件显示未读数count > 99 显示 "99+"
- 点击展开 `Popover`(宽 380px高 480px
- Popover 内容:
- 头部:"通知中心" + "全部已读" `Button`
- 列表:虚拟滚动,未读条目左侧蓝点
- 点击条目 → `api.put(/notifications/${id}/read)` + `navigate(link)`
- 底部 "查看全部 →" → `/notifications`
- 空状态:"暂无通知" 插画
### 6.3 全屏通知页 `/notifications`
- 左侧类型筛选 `Menu`(全部/账单/入住/班级/系统)
- 右侧通知列表 + `InfiniteScroll`
- 列表项:类型图标 + 标题 + 内容摘要 + 时间(相对时间 "3分钟前"
- 点击条目 → 标已读 + 跳转 `link`
### 6.4 SSE Hook (`useNotifications`)
```typescript
function useNotifications() {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const token = localStorage.getItem('token');
const es = new EventSource(`/api/notifications/stream?token=${token}`);
es.onmessage = (event) => {
const notification = JSON.parse(event.data);
setUnreadCount((c) => c + 1);
};
es.onerror = () => {
// SSE 断开,切换到轮询兜底
const interval = setInterval(async () => {
const { count } = await api.get('/notifications/unread-count');
setUnreadCount(count);
}, 60_000);
return () => clearInterval(interval);
};
return () => es.close();
}, []);
return { unreadCount };
}
```
SSE 认证URL query 传 JWT tokenEventSource 不支持自定义 header
## 7. SSE 认证与 Nginx 配置
### 7.1 后端 Guard 适配
`JwtAuthGuard` 需支持从 query string 提取 token当前仅从 `Authorization` header
```typescript
// 在 canActivate 中增加 fallback
const token = extractFromHeader(request) || request.query?.token;
```
### 7.2 Nginx 配置
SSE 长连接需关闭对该路径的 proxy buffering
```nginx
location /api/notifications/stream {
proxy_pass http://127.0.0.1:3000;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
```
## 8. 数据库迁移
TypeORM `synchronize: true` 自动建表。Entity 注册在 `apps/server/src/entities/index.ts``AppModule``TypeOrmModule.forFeature([Notification])`
## 9. 钉钉/企微外发(预留)
- `NotificationsService.create()` 后 emit `notification.created` 事件
- 钉钉模块(`apps/server/src/sync/` 下已有工作通知能力)监听该事件
- 根据 `notification.type` 判断是否外发(如 `bill_generated` 发钉钉,`announcement` 仅站内信)
- 外发失败不影响站内信记录,日志告警即可
## 10. 扩展点(学生端未来接入)
- 学生端前端独立部署时,复用同一套 APIJWT 认证统一)
- `link` 字段路径由前端根据当前角色拼接 base path
- 通知类型枚举预留 `student_*` 前缀扩展