# 站内信通知中心 — 实现计划

> **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"
```
