---
description: "后台里的低频提醒收口：头部一个铃铛，右上角一颗红点写未读数，点开落下一列最新事件 —— @ 提到、评论回复、系统消息、异常告警塞进同一个面板，按类型着色、按时间倒序。点单条标为已读，或按面板底部按钮一次清完"
---

# 通知中心（铃铛徽标 + 下拉面板）

后台里的低频提醒收口：头部一个铃铛，右上角一颗红点写未读数，点开落下一列最新事件 —— @ 提到、评论回复、系统消息、异常告警塞进同一个面板，按类型着色、按时间倒序。点单条标为已读，或按面板底部按钮一次清完。

<iframe src="/skills/dashboard/assets/demo-notifications.html"
        style="width:100%;height:620px;border:1px solid #8884;border-radius:10px"
        loading="lazy" title="形状 demo：通知中心"></iframe>

试这几下：

- 点右上角**铃铛**打开面板；点面板外任意处（包括页面里那两个控制按钮）都收起
- 每条右侧**「标为已读」** —— 未读圆点消失、徽标数字减一、这条淡化（仍在列表里）
- **「全部标为已读」** 一次清完，徽标消失；再点已 disabled
- **「清空所有」** 是破坏性动作，先弹确认；确认后面板走**空态**图 + 文案
- **「模拟新通知」** 从事件池抽一条 `read: false` 塞到最前面 —— 造出 >9 条未读看徽标 cap 成 `9+`
- 四种类型图标 + 语义色：**提及（绿）/ 评论（蓝）/ 系统（灰）/ 告警（黄）**

## 规矩

- **Controlled**：面板只渲染传入的 `notifications` 数组，读态由 owner（store / query）持有，组件内不做二次副本。demo 的 owner 是 `App` 里的 `useState`，真实版换成 `notificationsListQuery()` 的 `data`
- **未读徽标 = 计 `!read` 条数**，超过 `badgeCap`（默认 9）显示 `9+`；无未读时徽标隐藏；`aria-label` 一并带上未读数
- **类型 → 图标 + 语义色一处集中**（demo 里是 `KIND_ICON` 表 + `.noti-icon.<kind>` 配色；真实版是 `KIND_ICON` / `KIND_CLASS` 双映射），加类型只加一项、不改渲染
- **demo vs 真实版数据面**：demo 数据全在内存，`markRead` 直接改 state；真实版走**两个查询**（`unreadCountQuery()` 喂徽标 + `notificationsListQuery()` 分页拉列表）和**一个 mutation**（`useMarkRead` / `useMarkAllRead` 写 `readAt`），mutation 命中后失效两个查询键让面板重取；`refetchInterval` 让新通知自己冒出来（详见 add-realtime）
- **Popover 契约**：点铃铛外任意处收起面板；列表限高滚动；空态一图一句 CTA；破坏性操作（"清空所有"）先过确认弹窗；瞬时反馈一律 toast

> 蓝本：`add-notifications.md`[^add-notifications]（open-dashboard @ `aa9815f`，MIT，Invariants 已消化进上面「规矩」）

??? abstract "demo 源码：`assets/demo-notifications.html`（自包含、未压缩）"

    ```html
    --8<-- "skills/dashboard/assets/demo-notifications.html"
    ```

[^add-notifications]: [`add-notifications.md`](https://github.com/ahpxex/open-dashboard/blob/aa9815f426e9520841c4aa15a341bfd9f866091f/.claude/skills/add-component/references/add-notifications.md)，2026-07-28 读。
