1274 lines
35 KiB
Markdown
1274 lines
35 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:** 为系统全部角色提供站内信通知中心,支持 SSE 实时推送 + 轮询兜底,预留钉钉/企微外发扩展点。
|
||
|
||
**Architecture:** 新增 `notifications` 表 + `NotificationsModule`,NestJS `@Sse()` 实现 SSE 推送,前端 `EventSource` + `useNotifications` hook,Header 铃铛 Badge + Popover + 全屏通知页。
|
||
|
||
**Tech Stack:** NestJS 11, TypeORM 0.3, RxJS, React 19, Ant Design 6, EventSource API
|
||
|
||
## Global Constraints
|
||
|
||
- 表名使用复数形式 `notifications`
|
||
- Entity 使用 `@Entity('notifications')` + `@Column({ name: 'snake_case' })` 模式
|
||
- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出
|
||
- Module 必须 `imports: [TypeOrmModule.forFeature([Notification])]`
|
||
- Controller 所有方法 `@UseGuards(JwtAuthGuard)`
|
||
- DTO 使用 class-validator 装饰器
|
||
- 前端 axios 实例从 `api/` 导入
|
||
- 前端新增路由在 `App.tsx` 注册
|
||
- SSE 端点需支持从 query string 提取 JWT token(EventSource 不支持自定义 header)
|
||
|
||
---
|
||
|
||
### Task 1: Notification Entity
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/entities/notification.entity.ts`
|
||
- Modify: `apps/server/src/entities/index.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Notification` entity class — exports for TypeORM `@Entity('notifications')`
|
||
|
||
- [ ] **Step 1: 创建 notification.entity.ts**
|
||
|
||
```typescript
|
||
// apps/server/src/entities/notification.entity.ts
|
||
import {
|
||
Entity,
|
||
PrimaryGeneratedColumn,
|
||
Column,
|
||
CreateDateColumn,
|
||
ManyToOne,
|
||
JoinColumn,
|
||
} from 'typeorm';
|
||
import { User } from './user.entity';
|
||
|
||
export enum NotificationType {
|
||
BILL_GENERATED = 'bill_generated',
|
||
BILL_PAID = 'bill_paid',
|
||
CHECK_IN = 'check_in',
|
||
CHECK_OUT = 'check_out',
|
||
DEPOSIT_DUE = 'deposit_due',
|
||
DEPOSIT_REFUNDED = 'deposit_refunded',
|
||
CLASS_CHANGE = 'class_change',
|
||
SCHEDULE_CONFLICT = 'schedule_conflict',
|
||
ANNOUNCEMENT = 'announcement',
|
||
}
|
||
|
||
@Entity('notifications')
|
||
export class Notification {
|
||
@PrimaryGeneratedColumn()
|
||
id: number;
|
||
|
||
@Column({ name: 'recipient_id', type: 'integer' })
|
||
recipientId: number;
|
||
|
||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||
@JoinColumn({ name: 'recipient_id' })
|
||
recipient: User;
|
||
|
||
@Column({ name: 'type', length: 30 })
|
||
type: string;
|
||
|
||
@Column({ name: 'title', length: 200 })
|
||
title: string;
|
||
|
||
@Column({ name: 'content', type: 'text', nullable: true })
|
||
content: string;
|
||
|
||
@Column({ name: 'link', length: 500, nullable: true })
|
||
link: string;
|
||
|
||
@Column({ name: 'is_read', type: 'boolean', default: false })
|
||
isRead: boolean;
|
||
|
||
@Column({ name: 'read_at', type: 'datetime', nullable: true })
|
||
readAt: Date;
|
||
|
||
@CreateDateColumn({ name: 'created_at' })
|
||
createdAt: Date;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 entities/index.ts 中注册导出**
|
||
|
||
在 `apps/server/src/entities/index.ts` 末尾添加:
|
||
```typescript
|
||
export { Notification, NotificationType } from './notification.entity';
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 — 启动后端检查 TypeORM 自动建表**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev
|
||
```
|
||
|
||
Expected: 启动成功,`notifications` 表自动创建。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/entities/notification.entity.ts apps/server/src/entities/index.ts
|
||
git commit -m "feat: add Notification entity"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Notifications Module — DTO + Service
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/notifications/dto/notification.dto.ts`
|
||
- Create: `apps/server/src/notifications/notifications.service.ts`
|
||
- Create: `apps/server/src/notifications/notifications.module.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Notification` entity from Task 1
|
||
- Produces: `NotificationsService` with methods: `create`, `findByUser`, `getUnreadCount`, `markRead`, `markAllRead`, `subscribe`
|
||
|
||
- [ ] **Step 1: 创建 DTO**
|
||
|
||
```typescript
|
||
// apps/server/src/notifications/dto/notification.dto.ts
|
||
import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt, IsEnum } from 'class-validator';
|
||
import { NotificationType } from '../../entities/notification.entity';
|
||
|
||
export class CreateNotificationDto {
|
||
@IsArray()
|
||
@IsInt({ each: true })
|
||
recipientIds: number[];
|
||
|
||
@IsString()
|
||
@IsNotEmpty()
|
||
type: string;
|
||
|
||
@IsString()
|
||
@IsNotEmpty()
|
||
title: string;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
content?: string;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
link?: string;
|
||
}
|
||
|
||
export class NotificationQueryDto {
|
||
@IsOptional()
|
||
@IsInt()
|
||
after?: number;
|
||
|
||
@IsOptional()
|
||
@IsInt()
|
||
limit?: number;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 Service**
|
||
|
||
```typescript
|
||
// apps/server/src/notifications/notifications.service.ts
|
||
import { Injectable } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository, LessThan } from 'typeorm';
|
||
import { Subject, Observable } from 'rxjs';
|
||
import { filter } from 'rxjs/operators';
|
||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||
import { Notification } from '../entities/notification.entity';
|
||
import { CreateNotificationDto } from './dto/notification.dto';
|
||
|
||
@Injectable()
|
||
export class NotificationsService {
|
||
private subjects = new Map<number, Subject<Notification>>();
|
||
|
||
constructor(
|
||
@InjectRepository(Notification)
|
||
private repo: Repository<Notification>,
|
||
private eventEmitter: EventEmitter2,
|
||
) {}
|
||
|
||
async create(dto: CreateNotificationDto): Promise<Notification[]> {
|
||
const notifications = dto.recipientIds.map((recipientId) =>
|
||
this.repo.create({
|
||
recipientId,
|
||
type: dto.type,
|
||
title: dto.title,
|
||
content: dto.content ?? '',
|
||
link: dto.link ?? null,
|
||
}),
|
||
);
|
||
const saved = await this.repo.save(notifications);
|
||
|
||
// 推送 SSE + emit 事件
|
||
for (const n of saved) {
|
||
this.subjects.get(n.recipientId)?.next(n);
|
||
this.eventEmitter.emit('notification.created', n);
|
||
}
|
||
|
||
return saved;
|
||
}
|
||
|
||
async findByUser(
|
||
userId: number,
|
||
after?: number,
|
||
limit: number = 20,
|
||
): Promise<Notification[]> {
|
||
const qb = this.repo
|
||
.createQueryBuilder('n')
|
||
.where('n.recipientId = :userId', { userId })
|
||
.orderBy('n.createdAt', 'DESC')
|
||
.take(limit);
|
||
|
||
if (after) {
|
||
qb.andWhere('n.id < :after', { after });
|
||
}
|
||
|
||
return qb.getMany();
|
||
}
|
||
|
||
async getUnreadCount(userId: number): Promise<number> {
|
||
return this.repo.count({
|
||
where: { recipientId: userId, isRead: false },
|
||
});
|
||
}
|
||
|
||
async markRead(id: number, userId: number): Promise<void> {
|
||
await this.repo.update(
|
||
{ id, recipientId: userId },
|
||
{ isRead: true, readAt: new Date() },
|
||
);
|
||
}
|
||
|
||
async markAllRead(userId: number): Promise<void> {
|
||
await this.repo.update(
|
||
{ recipientId: userId, isRead: false },
|
||
{ isRead: true, readAt: new Date() },
|
||
);
|
||
}
|
||
|
||
subscribe(userId: number): Observable<Notification> {
|
||
if (!this.subjects.has(userId)) {
|
||
this.subjects.set(userId, new Subject<Notification>());
|
||
}
|
||
return this.subjects.get(userId)!.asObservable();
|
||
}
|
||
|
||
unsubscribe(userId: number): void {
|
||
const subj = this.subjects.get(userId);
|
||
if (subj) {
|
||
subj.complete();
|
||
this.subjects.delete(userId);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 Module**
|
||
|
||
```typescript
|
||
// apps/server/src/notifications/notifications.module.ts
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import { Notification } from '../entities/notification.entity';
|
||
import { NotificationsService } from './notifications.service';
|
||
import { NotificationsController } from './notifications.controller';
|
||
|
||
@Module({
|
||
imports: [TypeOrmModule.forFeature([Notification])],
|
||
controllers: [NotificationsController],
|
||
providers: [NotificationsService],
|
||
exports: [NotificationsService],
|
||
})
|
||
export class NotificationsModule {}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/notifications/
|
||
git commit -m "feat: add NotificationsService with SSE subject pool"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Notifications Controller + SSE Endpoint
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/notifications/notifications.controller.ts`
|
||
- Modify: `apps/server/src/app.module.ts` — 注册 NotificationsModule
|
||
|
||
**Interfaces:**
|
||
- Consumes: `NotificationsService` from Task 2
|
||
- Produces: REST API + SSE stream endpoint
|
||
|
||
- [ ] **Step 1: 创建 Controller**
|
||
|
||
```typescript
|
||
// apps/server/src/notifications/notifications.controller.ts
|
||
import {
|
||
Controller,
|
||
Get,
|
||
Put,
|
||
Param,
|
||
Query,
|
||
Req,
|
||
Sse,
|
||
UseGuards,
|
||
} from '@nestjs/common';
|
||
import { Request } from 'express';
|
||
import { Observable, map } from 'rxjs';
|
||
import { NotificationsService } from './notifications.service';
|
||
import { NotificationQueryDto } from './dto/notification.dto';
|
||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||
|
||
@UseGuards(JwtAuthGuard)
|
||
@Controller('notifications')
|
||
export class NotificationsController {
|
||
constructor(private readonly service: NotificationsService) {}
|
||
|
||
@Get()
|
||
async findAll(@Req() req: any, @Query() query: NotificationQueryDto) {
|
||
const userId = req.user.id;
|
||
return this.service.findByUser(userId, query.after, query.limit ?? 20);
|
||
}
|
||
|
||
@Get('unread-count')
|
||
async unreadCount(@Req() req: any) {
|
||
const userId = req.user.id;
|
||
const count = await this.service.getUnreadCount(userId);
|
||
return { count };
|
||
}
|
||
|
||
@Sse('stream')
|
||
stream(@Req() req: any): Observable<MessageEvent> {
|
||
const userId = req.user.id;
|
||
return this.service.subscribe(userId).pipe(
|
||
map((notification) => ({
|
||
data: JSON.stringify({
|
||
id: notification.id,
|
||
type: notification.type,
|
||
title: notification.title,
|
||
content: notification.content,
|
||
link: notification.link,
|
||
createdAt: notification.createdAt,
|
||
}),
|
||
} as MessageEvent)),
|
||
);
|
||
}
|
||
|
||
@Put(':id/read')
|
||
async markRead(@Param('id') id: string, @Req() req: any) {
|
||
await this.service.markRead(+id, req.user.id);
|
||
return { success: true };
|
||
}
|
||
|
||
@Put('read-all')
|
||
async markAllRead(@Req() req: any) {
|
||
await this.service.markAllRead(req.user.id);
|
||
return { success: true };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 AppModule 中注册 NotificationsModule**
|
||
|
||
在 `apps/server/src/app.module.ts` 中:
|
||
1. 在 imports 数组中添加 `NotificationsModule,`
|
||
2. 在 entities 数组中添加 `Notification,`
|
||
|
||
```typescript
|
||
// 在 TypeOrmModule.forRootAsync 的 allEntities 数组中添加:
|
||
Notification,
|
||
|
||
// 在 @Module imports 中添加:
|
||
NotificationsModule,
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 — 启动后端测试 API**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev
|
||
```
|
||
|
||
Expected: 启动成功。用 curl 测试(先登录获取 token):
|
||
```bash
|
||
# 获取未读数
|
||
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/notifications/unread-count
|
||
# Expected: {"count":0}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/notifications/notifications.controller.ts apps/server/src/app.module.ts
|
||
git commit -m "feat: add NotificationsController with SSE stream endpoint"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: JWT SSE 认证适配
|
||
|
||
**Files:**
|
||
- Modify: `apps/server/src/auth/guards/jwt-auth.guard.ts`
|
||
|
||
**Interfaces:**
|
||
- Modifies: `JwtAuthGuard` — SSE 请求从 query string 提取 token 作为 fallback
|
||
|
||
- [ ] **Step 1: 修改 JwtAuthGuard 支持 query token**
|
||
|
||
```typescript
|
||
// apps/server/src/auth/guards/jwt-auth.guard.ts
|
||
// 在已有的 JwtAuthGuard 类中,重写 getRequest 或在 canActivate 前增加 extractor
|
||
|
||
// 方案:创建自定义 guard 扩展
|
||
```
|
||
|
||
实际上 `passport-jwt` 的 `ExtractJwt.fromAuthHeaderAsBearerToken()` 不支持 query。需要在 strategy 层面处理。
|
||
|
||
修改 `apps/server/src/auth/strategies/jwt.strategy.ts`:
|
||
|
||
```typescript
|
||
// apps/server/src/auth/strategies/jwt.strategy.ts
|
||
import { Injectable } from '@nestjs/common';
|
||
import { PassportStrategy } from '@nestjs/passport';
|
||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { Request } from 'express';
|
||
|
||
@Injectable()
|
||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||
constructor(config: ConfigService) {
|
||
super({
|
||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||
// 1. 标准 Bearer header
|
||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||
// 2. SSE 场景:query string ?token=
|
||
(req: Request) => {
|
||
const token = req?.query?.token;
|
||
if (typeof token === 'string' && token.length > 0) {
|
||
return token;
|
||
}
|
||
return null;
|
||
},
|
||
]),
|
||
```
|
||
|
||
保留 `ignoreExpiration` 和 `secretOrKey` 不变。
|
||
|
||
- [ ] **Step 2: 验证 — SSE 端点认证**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev
|
||
# 用浏览器或 curl 测试 SSE:
|
||
curl -N "http://localhost:3000/api/notifications/stream?token=<jwt_token>"
|
||
# Expected: 连接保持,无错误
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/auth/strategies/jwt.strategy.ts
|
||
git commit -m "feat: support JWT from query string for SSE endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 事件监听 + 钉钉/企微外发(预留)
|
||
|
||
**Files:**
|
||
- Modify: `apps/server/src/notifications/notifications.module.ts` — 注册 EventEmitter
|
||
|
||
**Interfaces:**
|
||
- Produces: `notification.created` 事件发射,供钉钉/企微模块异步监听
|
||
|
||
- [ ] **Step 1: 安装依赖**
|
||
|
||
```bash
|
||
cd apps/server && npm install @nestjs/event-emitter
|
||
```
|
||
|
||
- [ ] **Step 2: 注册 EventEmitterModule**
|
||
|
||
确保 `apps/server/src/app.module.ts` 中已引入 `EventEmitterModule.forRoot()`。检查是否已存在:
|
||
|
||
```bash
|
||
grep -r "EventEmitter" apps/server/src/app.module.ts
|
||
```
|
||
|
||
如果不存在,添加:
|
||
```typescript
|
||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||
|
||
// 在 @Module imports 中添加:
|
||
EventEmitterModule.forRoot(),
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 — 事件发射不报错**
|
||
|
||
Service 中的 `this.eventEmitter.emit('notification.created', n)` 应在 EventEmitter 注册后正常工作。启动后端确认无报错。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 前端 — useNotifications Hook + NotificationBell 组件
|
||
|
||
**Files:**
|
||
- Create: `apps/admin/src/hooks/useNotifications.ts`
|
||
- Create: `apps/admin/src/components/NotificationBell.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `/api/notifications/unread-count` and `/api/notifications/stream`
|
||
- Produces: `useNotifications` hook, `NotificationBell` component
|
||
|
||
- [ ] **Step 1: 创建 useNotifications hook**
|
||
|
||
```typescript
|
||
// apps/admin/src/hooks/useNotifications.ts
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import api from '../api';
|
||
|
||
interface Notification {
|
||
id: number;
|
||
type: string;
|
||
title: string;
|
||
content: string;
|
||
link: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
export function useNotifications() {
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
const [latestNotification, setLatestNotification] = useState<Notification | null>(null);
|
||
|
||
const fetchUnreadCount = useCallback(async () => {
|
||
try {
|
||
const data = await api.get('/notifications/unread-count') as unknown as { count: number };
|
||
setUnreadCount(data.count);
|
||
} catch {
|
||
// 静默失败
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// 初始加载
|
||
fetchUnreadCount();
|
||
|
||
const token = localStorage.getItem('token');
|
||
if (!token) return;
|
||
|
||
// SSE 连接
|
||
const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);
|
||
|
||
es.onmessage = (event) => {
|
||
try {
|
||
const notification = JSON.parse(event.data) as Notification;
|
||
setUnreadCount((c) => c + 1);
|
||
setLatestNotification(notification);
|
||
} catch {
|
||
// 解析失败忽略
|
||
}
|
||
};
|
||
|
||
es.onerror = () => {
|
||
// SSE 断开 → 切换到轮询兜底
|
||
es.close();
|
||
const interval = setInterval(() => {
|
||
fetchUnreadCount();
|
||
}, 60_000);
|
||
return () => clearInterval(interval);
|
||
};
|
||
|
||
return () => {
|
||
es.close();
|
||
};
|
||
}, [fetchUnreadCount]);
|
||
|
||
const markAsRead = useCallback(async (id: number) => {
|
||
try {
|
||
await api.put(`/notifications/${id}/read`);
|
||
setUnreadCount((c) => Math.max(0, c - 1));
|
||
} catch {
|
||
// 静默失败
|
||
}
|
||
}, []);
|
||
|
||
const markAllAsRead = useCallback(async () => {
|
||
try {
|
||
await api.put('/notifications/read-all');
|
||
setUnreadCount(0);
|
||
} catch {
|
||
// 静默失败
|
||
}
|
||
}, []);
|
||
|
||
return { unreadCount, latestNotification, markAsRead, markAllAsRead };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 NotificationBell 组件**
|
||
|
||
```tsx
|
||
// apps/admin/src/components/NotificationBell.tsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import { Badge, Popover, Button, List, Typography, Empty, Space } from 'antd';
|
||
import { BellOutlined } from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import api from '../api';
|
||
|
||
interface NotificationItem {
|
||
id: number;
|
||
type: string;
|
||
title: string;
|
||
content: string;
|
||
link: string | null;
|
||
isRead: boolean;
|
||
createdAt: string;
|
||
}
|
||
|
||
const typeLabels: Record<string, string> = {
|
||
bill_generated: '账单',
|
||
bill_paid: '账单',
|
||
check_in: '入住',
|
||
check_out: '退宿',
|
||
deposit_due: '押金',
|
||
deposit_refunded: '押金',
|
||
class_change: '班级',
|
||
schedule_conflict: '排课',
|
||
announcement: '公告',
|
||
};
|
||
|
||
function timeAgo(dateStr: string): string {
|
||
const diff = Date.now() - new Date(dateStr).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return '刚刚';
|
||
if (mins < 60) return `${mins}分钟前`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return `${hours}小时前`;
|
||
const days = Math.floor(hours / 24);
|
||
return `${days}天前`;
|
||
}
|
||
|
||
const NotificationBell: React.FC = () => {
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||
const [open, setOpen] = useState(false);
|
||
const navigate = useNavigate();
|
||
|
||
const fetchNotifications = async () => {
|
||
try {
|
||
const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[];
|
||
setNotifications(data);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
const fetchUnread = async () => {
|
||
try {
|
||
const data = await api.get('/notifications/unread-count') as unknown as { count: number };
|
||
setUnreadCount(data.count);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchUnread();
|
||
// SSE
|
||
const token = localStorage.getItem('token');
|
||
if (!token) return;
|
||
const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);
|
||
es.onmessage = (event) => {
|
||
try {
|
||
JSON.parse(event.data);
|
||
setUnreadCount((c) => c + 1);
|
||
if (open) fetchNotifications();
|
||
} catch { /* ignore */ }
|
||
};
|
||
es.onerror = () => {
|
||
es.close();
|
||
const interval = setInterval(fetchUnread, 60_000);
|
||
return () => clearInterval(interval);
|
||
};
|
||
return () => es.close();
|
||
}, [open]);
|
||
|
||
const handleOpen = (visible: boolean) => {
|
||
setOpen(visible);
|
||
if (visible) fetchNotifications();
|
||
};
|
||
|
||
const handleClick = async (item: NotificationItem) => {
|
||
if (!item.isRead) {
|
||
try {
|
||
await api.put(`/notifications/${item.id}/read`);
|
||
setUnreadCount((c) => Math.max(0, c - 1));
|
||
} catch { /* ignore */ }
|
||
}
|
||
setOpen(false);
|
||
if (item.link) navigate(item.link);
|
||
};
|
||
|
||
const handleMarkAll = async () => {
|
||
try {
|
||
await api.put('/notifications/read-all');
|
||
setUnreadCount(0);
|
||
setNotifications((prev) =>
|
||
prev.map((n) => ({ ...n, isRead: true })),
|
||
);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
const content = (
|
||
<div style={{ width: 380, maxHeight: 480 }}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: '12px 16px',
|
||
borderBottom: '1px solid #f0f0f0',
|
||
}}
|
||
>
|
||
<Typography.Text strong>通知中心</Typography.Text>
|
||
<Button type="link" size="small" onClick={handleMarkAll}>
|
||
全部已读
|
||
</Button>
|
||
</div>
|
||
{notifications.length === 0 ? (
|
||
<div style={{ padding: 40 }}>
|
||
<Empty description="暂无通知" />
|
||
</div>
|
||
) : (
|
||
<List
|
||
style={{ maxHeight: 380, overflow: 'auto' }}
|
||
dataSource={notifications}
|
||
renderItem={(item) => (
|
||
<List.Item
|
||
onClick={() => handleClick(item)}
|
||
style={{
|
||
padding: '12px 16px',
|
||
cursor: 'pointer',
|
||
backgroundColor: item.isRead ? 'transparent' : '#f0f7ff',
|
||
}}
|
||
>
|
||
<List.Item.Meta
|
||
avatar={
|
||
!item.isRead && (
|
||
<div
|
||
style={{
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: '50%',
|
||
backgroundColor: '#007aff',
|
||
marginTop: 6,
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
title={
|
||
<Typography.Text
|
||
strong={!item.isRead}
|
||
style={{ fontSize: 14 }}
|
||
>
|
||
[{typeLabels[item.type] || item.type}] {item.title}
|
||
</Typography.Text>
|
||
}
|
||
description={
|
||
<Typography.Text
|
||
type="secondary"
|
||
style={{ fontSize: 12 }}
|
||
>
|
||
{timeAgo(item.createdAt)}
|
||
</Typography.Text>
|
||
}
|
||
/>
|
||
</List.Item>
|
||
)}
|
||
/>
|
||
)}
|
||
<div
|
||
style={{
|
||
borderTop: '1px solid #f0f0f0',
|
||
padding: '8px 16px',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => {
|
||
setOpen(false);
|
||
navigate('/notifications');
|
||
}}
|
||
>
|
||
查看全部 →
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<Popover
|
||
content={content}
|
||
trigger="click"
|
||
open={open}
|
||
onOpenChange={handleOpen}
|
||
placement="bottomRight"
|
||
>
|
||
<Badge count={unreadCount} size="small" offset={[-2, 2]}>
|
||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
||
</Badge>
|
||
</Popover>
|
||
);
|
||
};
|
||
|
||
export default NotificationBell;
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src/hooks/useNotifications.ts apps/admin/src/components/NotificationBell.tsx
|
||
git commit -m "feat: add useNotifications hook and NotificationBell component"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 前端 — MainLayout 集成铃铛
|
||
|
||
**Files:**
|
||
- Modify: `apps/admin/src/layouts/MainLayout.tsx`
|
||
|
||
- [ ] **Step 1: 在 Header 中添加 NotificationBell**
|
||
|
||
在 `MainLayout.tsx` 的 Header 右侧区域(用户头像/下拉菜单旁边)添加 `NotificationBell`:
|
||
|
||
```tsx
|
||
// apps/admin/src/layouts/MainLayout.tsx
|
||
// 在 imports 中添加:
|
||
import NotificationBell from '../components/NotificationBell';
|
||
|
||
// 在 Header 右侧区域(通常在用户 Dropdown 之前):
|
||
<NotificationBell />
|
||
```
|
||
|
||
具体定位:找到 Header 中 `Dropdown` / `Avatar` 相关的 JSX,在其前面插入 `<NotificationBell />`。
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src/layouts/MainLayout.tsx
|
||
git commit -m "feat: integrate NotificationBell into MainLayout header"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 前端 — 全屏通知页
|
||
|
||
**Files:**
|
||
- Create: `apps/admin/src/pages/Notifications/index.tsx`
|
||
- Modify: `apps/admin/src/App.tsx` — 注册路由
|
||
|
||
- [ ] **Step 1: 创建 Notifications 页面**
|
||
|
||
```tsx
|
||
// apps/admin/src/pages/Notifications/index.tsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import { List, Typography, Menu, Layout, Button, Empty, Spin } from 'antd';
|
||
import {
|
||
BellOutlined,
|
||
DollarOutlined,
|
||
HomeOutlined,
|
||
TeamOutlined,
|
||
SettingOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import api from '../../api';
|
||
|
||
const { Sider, Content } = Layout;
|
||
|
||
interface NotificationItem {
|
||
id: number;
|
||
type: string;
|
||
title: string;
|
||
content: string;
|
||
link: string | null;
|
||
isRead: boolean;
|
||
createdAt: string;
|
||
}
|
||
|
||
const typeMap: Record<string, { label: string; icon: React.ReactNode }> = {
|
||
bill_generated: { label: '账单', icon: <DollarOutlined /> },
|
||
bill_paid: { label: '账单', icon: <DollarOutlined /> },
|
||
check_in: { label: '入住', icon: <HomeOutlined /> },
|
||
check_out: { label: '退宿', icon: <HomeOutlined /> },
|
||
deposit_due: { label: '押金', icon: <DollarOutlined /> },
|
||
deposit_refunded: { label: '押金', icon: <DollarOutlined /> },
|
||
class_change: { label: '班级', icon: <TeamOutlined /> },
|
||
schedule_conflict: { label: '排课', icon: <BellOutlined /> },
|
||
announcement: { label: '公告', icon: <SettingOutlined /> },
|
||
};
|
||
|
||
function timeAgo(dateStr: string): string {
|
||
const diff = Date.now() - new Date(dateStr).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return '刚刚';
|
||
if (mins < 60) return `${mins}分钟前`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return `${hours}小时前`;
|
||
const days = Math.floor(hours / 24);
|
||
if (days < 7) return `${days}天前`;
|
||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||
}
|
||
|
||
const NotificationsPage: React.FC = () => {
|
||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||
const [filter, setFilter] = useState('all');
|
||
const [loading, setLoading] = useState(false);
|
||
const navigate = useNavigate();
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[];
|
||
setNotifications(data);
|
||
} catch { /* ignore */ }
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, []);
|
||
|
||
const handleClick = async (item: NotificationItem) => {
|
||
if (!item.isRead) {
|
||
try {
|
||
await api.put(`/notifications/${item.id}/read`);
|
||
setNotifications((prev) =>
|
||
prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
|
||
);
|
||
} catch { /* ignore */ }
|
||
}
|
||
if (item.link) navigate(item.link);
|
||
};
|
||
|
||
const handleMarkAll = async () => {
|
||
try {
|
||
await api.put('/notifications/read-all');
|
||
setNotifications((prev) =>
|
||
prev.map((n) => ({ ...n, isRead: true })),
|
||
);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
const filtered = filter === 'all'
|
||
? notifications
|
||
: notifications.filter((n) => n.type === filter);
|
||
|
||
return (
|
||
<Layout style={{ minHeight: '100%', background: '#fff' }}>
|
||
<Sider width={180} style={{ background: '#fff', borderRight: '1px solid #f0f0f0' }}>
|
||
<Menu
|
||
mode="inline"
|
||
selectedKeys={[filter]}
|
||
onClick={({ key }) => setFilter(key)}
|
||
items={[
|
||
{ key: 'all', icon: <BellOutlined />, label: '全部' },
|
||
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
|
||
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
|
||
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
|
||
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' },
|
||
]}
|
||
/>
|
||
</Sider>
|
||
<Content style={{ padding: 24 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>通知中心</Typography.Title>
|
||
<Button onClick={handleMarkAll}>全部已读</Button>
|
||
</div>
|
||
<Spin spinning={loading}>
|
||
{filtered.length === 0 ? (
|
||
<Empty description="暂无通知" />
|
||
) : (
|
||
<List
|
||
dataSource={filtered}
|
||
renderItem={(item) => {
|
||
const meta = typeMap[item.type] || { label: item.type, icon: <BellOutlined /> };
|
||
return (
|
||
<List.Item
|
||
onClick={() => handleClick(item)}
|
||
style={{
|
||
cursor: 'pointer',
|
||
padding: '16px 0',
|
||
backgroundColor: item.isRead ? 'transparent' : '#f0f7ff',
|
||
}}
|
||
>
|
||
<List.Item.Meta
|
||
avatar={
|
||
<div
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: '50%',
|
||
background: '#f0f0f0',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{meta.icon}
|
||
</div>
|
||
}
|
||
title={
|
||
<Space>
|
||
<Typography.Text
|
||
strong={!item.isRead}
|
||
style={{ fontSize: 15 }}
|
||
>
|
||
{item.title}
|
||
</Typography.Text>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{timeAgo(item.createdAt)}
|
||
</Typography.Text>
|
||
</Space>
|
||
}
|
||
description={
|
||
item.content && (
|
||
<Typography.Paragraph
|
||
type="secondary"
|
||
ellipsis={{ rows: 1 }}
|
||
style={{ marginBottom: 0 }}
|
||
>
|
||
{item.content}
|
||
</Typography.Paragraph>
|
||
)
|
||
}
|
||
/>
|
||
</List.Item>
|
||
);
|
||
}}
|
||
/>
|
||
)}
|
||
</Spin>
|
||
</Content>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default NotificationsPage;
|
||
```
|
||
|
||
- [ ] **Step 2: 在 App.tsx 注册路由**
|
||
|
||
在 `apps/admin/src/App.tsx` 中添加 import 和路由:
|
||
|
||
```tsx
|
||
import NotificationsPage from './pages/Notifications';
|
||
|
||
// 在 Routes 内添加:
|
||
<Route path="/notifications" element={
|
||
<PrivateRoute>
|
||
<MainLayout />
|
||
</PrivateRoute>
|
||
}>
|
||
<Route index element={<NotificationsPage />} />
|
||
</Route>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/admin/src/pages/Notifications/index.tsx apps/admin/src/App.tsx
|
||
git commit -m "feat: add Notifications full page with sidebar filter"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 业务模块集成 — 通知创建点
|
||
|
||
**Files:**
|
||
- Modify: `apps/server/src/bills/bills.controller.ts`
|
||
- Modify: `apps/server/src/occupancies/occupancies.controller.ts`
|
||
- Modify: `apps/server/src/deposits/deposits.controller.ts`
|
||
- Modify: `apps/server/src/classes/classes.controller.ts`
|
||
- Modify: `apps/server/src/schedules/schedules.controller.ts`
|
||
- 各 module 文件注入 NotificationsModule
|
||
|
||
**Interfaces:**
|
||
- Consumes: `NotificationsService.create()` from Task 2
|
||
|
||
- [ ] **Step 1: 各 Module 注入 NotificationsModule**
|
||
|
||
在每个需要发送通知的 module 的 `imports` 中添加 `NotificationsModule`:
|
||
|
||
```typescript
|
||
// bills.module.ts, occupancies.module.ts, deposits.module.ts, classes.module.ts, schedules.module.ts
|
||
import { NotificationsModule } from '../notifications/notifications.module';
|
||
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([...]),
|
||
NotificationsModule, // 新增
|
||
],
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 账单模块 — 生成/状态变更通知**
|
||
|
||
```typescript
|
||
// bills.controller.ts
|
||
import { NotificationsService } from '../notifications/notifications.service';
|
||
|
||
// constructor 注入:
|
||
constructor(
|
||
private notificationsService: NotificationsService,
|
||
) {}
|
||
|
||
// POST /bills/generate 方法末尾:
|
||
const notificationInfo = await this.billsService.getNotificationInfo(result);
|
||
await this.notificationsService.create({
|
||
recipientIds: notificationInfo.studentUserIds,
|
||
type: 'bill_generated',
|
||
title: '账单已生成',
|
||
content: `您的 ${notificationInfo.periodLabel} 账单已生成,总额 ¥${notificationInfo.totalAmount}`,
|
||
link: `/bills/${result.id}`,
|
||
});
|
||
|
||
// PUT /bills/:id/status (确认/已付) 末尾:
|
||
await this.notificationsService.create({
|
||
recipientIds: notificationInfo.studentUserIds,
|
||
type: 'bill_paid',
|
||
title: '账单状态更新',
|
||
content: `您的账单已被标记为${newStatus === 'confirmed' ? '已确认' : '已支付'}`,
|
||
link: `/bills/${id}`,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 入住模块 — 入住/退宿通知**
|
||
|
||
```typescript
|
||
// occupancies.controller.ts
|
||
// POST check-in:
|
||
await this.notificationsService.create({
|
||
recipientIds: [studentUserId],
|
||
type: 'check_in',
|
||
title: '入住登记',
|
||
content: `您已成功入住 ${roomLabel}`,
|
||
link: `/occupancies`,
|
||
});
|
||
|
||
// PUT check-out:
|
||
await this.notificationsService.create({
|
||
recipientIds: [studentUserId],
|
||
type: 'check_out',
|
||
title: '退宿确认',
|
||
content: `您已从 ${roomLabel} 退宿`,
|
||
link: `/occupancies`,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: 押金模块 — 催缴/退还通知**
|
||
|
||
```typescript
|
||
// deposits.controller.ts
|
||
// 收取押金:
|
||
await this.notificationsService.create({
|
||
recipientIds: [studentUserId],
|
||
type: 'deposit_due',
|
||
title: '押金催缴',
|
||
content: `请缴纳 ${amount} 元押金`,
|
||
link: `/deposits`,
|
||
});
|
||
|
||
// 退还押金:
|
||
await this.notificationsService.create({
|
||
recipientIds: [studentUserId],
|
||
type: 'deposit_refunded',
|
||
title: '押金退还',
|
||
content: `押金 ${amount} 元已退还`,
|
||
link: `/deposits`,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 班级模块 — 学员/教师变更通知**
|
||
|
||
```typescript
|
||
// classes.controller.ts
|
||
// 添加学员:
|
||
await this.notificationsService.create({
|
||
recipientIds: [headTeacherUserId],
|
||
type: 'class_change',
|
||
title: '学员变动',
|
||
content: `${studentNames.join('、')} 已加入 ${className}`,
|
||
link: `/classes/${classId}`,
|
||
});
|
||
|
||
// 添加教师:
|
||
await this.notificationsService.create({
|
||
recipientIds: [teacherUserId],
|
||
type: 'class_change',
|
||
title: '班级分配',
|
||
content: `您已被分配为 ${className} 的 ${roleLabel}`,
|
||
link: `/classes/${classId}`,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: 排课模块 — 冲突通知**
|
||
|
||
```typescript
|
||
// schedules.controller.ts
|
||
// 创建/编辑排课,冲突检测后:
|
||
if (conflict) {
|
||
// 通知相关教务人员
|
||
await this.notificationsService.create({
|
||
recipientIds: staffUserIds,
|
||
type: 'schedule_conflict',
|
||
title: '排课冲突',
|
||
content: `${classroomName} ${weekDayLabel} ${timeRange} 与已有排课冲突`,
|
||
link: `/schedules`,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/bills/ apps/server/src/occupancies/ apps/server/src/deposits/ apps/server/src/classes/ apps/server/src/schedules/
|
||
git commit -m "feat: integrate notification creation into business modules"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 验证 + 端到端测试
|
||
|
||
- [ ] **Step 1: 启动完整环境**
|
||
|
||
```bash
|
||
cd apps/server && npm run start:dev &
|
||
cd apps/admin && npm run dev &
|
||
```
|
||
|
||
- [ ] **Step 2: 浏览器测试流程**
|
||
|
||
1. 打开 `http://localhost:5173`,登录
|
||
2. 确认 Header 铃铛图标可见,未读数显示正确
|
||
3. 点击铃铛 → Popover 展开,显示通知列表
|
||
4. 执行一个业务操作(如生成账单)→ 对应学生用户的铃铛出现新通知
|
||
5. 点击通知 → 标已读 + 跳转
|
||
6. "全部已读" → 所有未读标记清除
|
||
7. "查看全部" → 进入 `/notifications` 全屏页
|
||
8. 左侧筛选 Tab 切换正常
|
||
|
||
- [ ] **Step 3: SSE 验证**
|
||
|
||
打开两个浏览器窗口(不同用户),一个执行操作,另一个实时看到通知推送。
|
||
|
||
- [ ] **Step 4: Commit (如有调整)**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix: notification integration tweaks"
|
||
```
|