Files
gongxue-base/docs/superpowers/plans/2026-07-05-notification-center.md

1274 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 站内信通知中心 — 实现计划
> **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` hookHeader 铃铛 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 tokenEventSource 不支持自定义 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"
```