forked from wangziqi/gongxue-base
fix: resolve all admin typecheck errors (typed api wrapper, unused imports, missing hooks, PermissionButton children)
This commit is contained in:
340
docs/superpowers/specs/2026-07-05-multi-campus-design.md
Normal file
340
docs/superpowers/specs/2026-07-05-multi-campus-design.md
Normal 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` 树结构
|
||||
233
docs/superpowers/specs/2026-07-05-notification-center-design.md
Normal file
233
docs/superpowers/specs/2026-07-05-notification-center-design.md
Normal 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 token(EventSource 不支持自定义 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. 扩展点(学生端未来接入)
|
||||
|
||||
- 学生端前端独立部署时,复用同一套 API(JWT 认证统一)
|
||||
- `link` 字段路径由前端根据当前角色拼接 base path
|
||||
- 通知类型枚举预留 `student_*` 前缀扩展
|
||||
Reference in New Issue
Block a user