35 KiB
站内信通知中心 — 实现计划
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:
Notificationentity class — exports for TypeORM@Entity('notifications') -
Step 1: 创建 notification.entity.ts
// 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 末尾添加:
export { Notification, NotificationType } from './notification.entity';
- Step 3: 验证 — 启动后端检查 TypeORM 自动建表
cd apps/server && npm run start:dev
Expected: 启动成功,notifications 表自动创建。
- Step 4: Commit
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:
Notificationentity from Task 1 -
Produces:
NotificationsServicewith methods:create,findByUser,getUnreadCount,markRead,markAllRead,subscribe -
Step 1: 创建 DTO
// 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
// 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
// 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
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:
NotificationsServicefrom Task 2 -
Produces: REST API + SSE stream endpoint
-
Step 1: 创建 Controller
// 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 中:
- 在 imports 数组中添加
NotificationsModule, - 在 entities 数组中添加
Notification,
// 在 TypeOrmModule.forRootAsync 的 allEntities 数组中添加:
Notification,
// 在 @Module imports 中添加:
NotificationsModule,
- Step 3: 验证 — 启动后端测试 API
cd apps/server && npm run start:dev
Expected: 启动成功。用 curl 测试(先登录获取 token):
# 获取未读数
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/notifications/unread-count
# Expected: {"count":0}
- Step 4: Commit
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
// 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:
// 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 端点认证
cd apps/server && npm run start:dev
# 用浏览器或 curl 测试 SSE:
curl -N "http://localhost:3000/api/notifications/stream?token=<jwt_token>"
# Expected: 连接保持,无错误
- Step 3: Commit
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: 安装依赖
cd apps/server && npm install @nestjs/event-emitter
- Step 2: 注册 EventEmitterModule
确保 apps/server/src/app.module.ts 中已引入 EventEmitterModule.forRoot()。检查是否已存在:
grep -r "EventEmitter" apps/server/src/app.module.ts
如果不存在,添加:
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 组件
// 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
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:
// apps/admin/src/layouts/MainLayout.tsx
// 在 imports 中添加:
import NotificationBell from '../components/NotificationBell';
// 在 Header 右侧区域(通常在用户 Dropdown 之前):
<NotificationBell />
具体定位:找到 Header 中 Dropdown / Avatar 相关的 JSX,在其前面插入 <NotificationBell />。
- Step 2: Commit
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 页面
// 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 和路由:
import NotificationsPage from './pages/Notifications';
// 在 Routes 内添加:
<Route path="/notifications" element={
<PrivateRoute>
<MainLayout />
</PrivateRoute>
}>
<Route index element={<NotificationsPage />} />
</Route>
- Step 3: Commit
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:
// 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: 账单模块 — 生成/状态变更通知
// 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: 入住模块 — 入住/退宿通知
// 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: 押金模块 — 催缴/退还通知
// 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: 班级模块 — 学员/教师变更通知
// 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: 排课模块 — 冲突通知
// schedules.controller.ts
// 创建/编辑排课,冲突检测后:
if (conflict) {
// 通知相关教务人员
await this.notificationsService.create({
recipientIds: staffUserIds,
type: 'schedule_conflict',
title: '排课冲突',
content: `${classroomName} ${weekDayLabel} ${timeRange} 与已有排课冲突`,
link: `/schedules`,
});
}
- Step 7: Commit
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: 启动完整环境
cd apps/server && npm run start:dev &
cd apps/admin && npm run dev &
- Step 2: 浏览器测试流程
- 打开
http://localhost:5173,登录 - 确认 Header 铃铛图标可见,未读数显示正确
- 点击铃铛 → Popover 展开,显示通知列表
- 执行一个业务操作(如生成账单)→ 对应学生用户的铃铛出现新通知
- 点击通知 → 标已读 + 跳转
- "全部已读" → 所有未读标记清除
- "查看全部" → 进入
/notifications全屏页 - 左侧筛选 Tab 切换正常
- Step 3: SSE 验证
打开两个浏览器窗口(不同用户),一个执行操作,另一个实时看到通知推送。
- Step 4: Commit (如有调整)
git add -A
git commit -m "fix: notification integration tweaks"