notification-center
Skill0 starsChanged 15 days agoMITView on GitHub
消息通知中心的设计与实现规范。包含入口铃铛与未读数、未读数的严格定义与清零时机、消息分类(系统/业务/互动)与 Tab 组织、列表项设计(未读态/摘要/相对时间)、单条与全部标记已读、进入即读的取舍、分页与加载更多、实时推送(WebSocket 与轮询)与断线重连、免打扰与夜间模式、跨端同步。当要做消息中心、站内信、通知铃铛、未读红点、或用户反馈"未读数不清零/重复打扰"时使用。
In this repository22 other files it gives its agents.
- api-contract-designSkill
- async-loading-stabilitySkill
- auth-pages-specSkill
- data-visualizationSkill
- deployment-opsSkill
- design-intelligenceSkill
- form-design-specSkill
- frontend-marketing-pagesSkill
- go-backend-architectureSkill
- i18n-specSkill
- jwt-session-authSkill
- media-file-managementSkill
- mobile-responsive-layoutSkill
- motion-animation-specSkill
- mysql-schema-designSkill
- react-frontend-architectureSkill
- rich-media-inputSkill
- table-advancedSkill
- testing-strategySkill
- ui-ux-decision-guideSkill
- web-project-bootstrapSkill
- web-tech-stackSkill
The file
Files
定位:未读数是承诺,不是装饰
用户看到「3 条未读」,就会期待正好是 3 条新内容。 数不准比不显示更伤信任。
一、入口与未读数
1.1 三种入口
| 入口 | 位置 | 适用 |
| 铃铛 + 红点 | 顶栏右上 | 默认,后台系统 |
| 铃铛 + 数字 | 顶栏右上 | 消息量大、需要精确数 |
| 独立消息页 | 侧边栏菜单项 | 消息是核心功能(IM、工单) |
1.2 未读数的显示规则
| 数量 | 显示 |
| 0 | 不显示任何标记(不是显示 0) |
| 1–99 | 精确数字 |
| ≥ 100 | 99+(不要显示 1000+ 那种长数字) |
<Badge count={unread} overflowCount={99} showZero={false} />showZero={false} 是关键:显示「0」会让用户以为有新消息但内容是空的。
1.3 红点 vs 数字
- 红点:只表示"有新的",不承诺数量(适合高频、低价值消息)
- 数字:明确承诺条数(适合低频、高价值消息,如审批、订单)
不要在同一个入口混用——要么都红点,要么都数字。
二、未读数的定义(最容易出错的地方)
2.1 「未读」到底指什么
必须明确三件事:
| 问题 | 选项 |
| 是"未点击"还是"未查看"? | 未查看(打开进入列表就算读,见 2.3) |
| 已删除的消息算不算? | 不算,删除时立即从未读数减去 |
| 过期的消息算不算? | 不算,过期要主动清理未读数 |
建议定义:未读数 = 该用户所有 status=unread 且未过期的消息条数
2.2 清零时机(三种模式,选一个)
| 模式 | 行为 | 适用 |
| 进入即读 | 打开消息中心 → 全部标记已读 | 后台系统默认 |
| 列表可见即读 | 滚动到可见的消息标记已读 | 消息量大 |
| 点击才读 | 点开某条才标记那条 | 需要精确追踪(工单、审批) |
不要混用。最常见的问题是:进入页面标了已读,但列表项还显示未读小圆点——用户困惑。
2.3 未读数的三个必做
- 乐观更新:点击后立即把角标清零,不等接口返回(失败再回滚)
- 服务端权威:每次进入页面以服务端返回的为准重新同步,不信本地缓存的数
- 跨端同步:A 端读了,B 端的角标要变(见第六节)
三、消息分类与 Tab
3.1 分类原则
| 分类 | 内容 | 特征 |
| 系统通知 | 版本更新、维护公告 | 无操作,只读 |
| 业务消息 | 订单状态、审批结果、库存预警 | 有跳转、有操作 |
| 互动消息 | 评论、@、点赞 | 高频,可聚合 |
分类 ≤ 4 个。超过就说明分类维度混乱(见 3.2)。
3.2 什么时候不该分类
消息总量 < 50 条/天时,不要分 Tab——直接一个列表,加了 Tab 反而增加点击成本。
判断标准:每个分类下的消息量是否都 > 5 条/天?不是就不分。
3.3 Tab 上的未读数
<Tabs items={[
{ key: 'all', label: '全部', children: <List /> },
{ key: 'business', label: <span>业务 <Badge count={3} /></span> },
]}/>只有分类未读数 > 0 时显示角标,为 0 时不显示。
四、列表项设计
4.1 结构
┌─────────────────────────────────────────┐
│ ● [图标] 标题 3 分钟前 │
│ 摘要文本,最多两行,超出省略… │
│ [操作按钮](可选) │
└─────────────────────────────────────────┘| 元素 | 规范 |
| 未读指示 | 左侧小圆点(6–8px,主色)或整行浅色背景,二选一 |
| 图标 | 按分类给不同图标与底色(系统=蓝、业务=橙、互动=绿) |
| 标题 | 一行,ellipsis |
| 摘要 | 两行,-webkit-line-clamp: 2 |
| 时间 | 相对时间(刚刚 / 3 分钟前 / 昨天 14:30 / 03-15),超过一年显示完整日期 |
| 操作 | 最多 2 个(查看 / 标记已读) |
4.2 已读与未读的视觉差异
不要只靠一个小圆点——要让用户扫一眼就能区分:
- 未读:圆点 + 标题加粗 + 背景
#f0f7ff(浅主色) - 已读:无圆点 + 标题常规 + 背景白
颜色对比要够,浅色背景在深色模式下要换。
4.3 时间格式
function relativeTime(date: Date): string {
const diff = Date.now() - date.getTime()
const min = 60_000, hour = 60 * min, day = 24 * hour
if (diff < min) return '刚刚'
if (diff < hour) return `${Math.floor(diff / min)} 分钟前`
if (diff < day) return `${Math.floor(diff / hour)} 小时前`
if (diff < 2 * day) return `昨天 ${format(date, 'HH:mm')}`
if (diff < 7 * day) return `${Math.floor(diff / day)} 天前`
if (date.getFullYear() === new Date().getFullYear()) return format(date, 'MM-DD')
return format(date, 'YYYY-MM-DD')
}不要显示「2026-10-28 21:05:33」这种精确时间——消息列表里没人关心秒。
五、标记已读
5.1 三种粒度
| 操作 | 实现 |
| 单条标记 | 点击某条时标记,或行内「标记已读」 |
| 分类全部标记 | Tab 内的「全部标为已读」按钮 |
| 全局全部标记 | 顶部的「全部标为已读」 |
「全部标为已读」必须二次确认——这是不可逆操作。
5.2 批量接口
// ✅ 一个接口
POST /api/notifications/read { ids: [1,2,3] } // 指定
POST /api/notifications/read { all: true } // 全部
POST /api/notifications/read { category: 'business' } // 按分类
// ❌ 不要循环调单条接口
for (const id of ids) await api.readOne(id)5.3 乐观更新 + 回滚
const markRead = useMutation({
mutationFn: api.markRead,
onMutate: async (ids) => {
const prev = queryClient.getQueryData(['notifications'])
queryClient.setQueryData(['notifications'], patchRead(prev, ids))
queryClient.setQueryData(['unreadCount'], (n: number) => Math.max(0, n - ids.length))
return { prev }
},
onError: (_e, _v, ctx) => {
queryClient.setQueryData(['notifications'], ctx.prev)
queryClient.invalidateQueries(['unreadCount'])
},
})未读数必须跟着一起乐观更新,否则点了"全部已读"角标还在。
六、实时推送
6.1 三种方案
| 方案 | 延迟 | 成本 | 适用 |
| 轮询(60s) | 高 | 低 | 消息量小、实时性要求低 |
| SSE(Server-Sent Events) | 低 | 中 | 单向推送,最简单 |
| WebSocket | 最低 | 高(要保活、重连) | 双向通信、IM |
后台系统默认用轮询(60 秒一次,只在页面可见时轮询)。要做实时再上 SSE。
6.2 轮询的正确实现
useQuery({
queryKey: ['unreadCount'],
queryFn: api.getUnreadCount,
refetchInterval: 60_000,
refetchIntervalInBackground: false, // ★ 页面不可见时不轮询
refetchOnWindowFocus: true, // ★ 切回来立即查一次
})refetchIntervalInBackground: false 必加——否则后台标签页一直在请求。
6.3 WebSocket 的必备处理
| 问题 | 处理 |
| 断线 | 指数退避重连(1s → 2s → 4s → 最大 30s) |
| 心跳 | 30s 一次 ping,60s 无 pong 就重连 |
| 重复消息 | 服务端给唯一 messageId,前端去重 |
| 顺序 | 带 seq,乱序时按 seq 排序 |
| 多标签页 | 只让一个标签建连(用 BroadcastChannel 转发) |
| 重连后补漏 | 重连时带 lastSeq 拉取遗漏的消息 |
「多标签页只建一个连接」经常被忽略:5 个标签页 = 5 个连接,服务端压力翻倍。用 BroadcastChannel 或 localStorage 选主。
七、免打扰
- 夜间免打扰:22:00–08:00 不弹桌面通知(站内消息照常收)
- 分类免打扰:某类消息不推送,但仍在列表里
- 勿扰模式下角标仍然累加,只是不弹提醒
免打扰不等于不接收——消息要存,只是不打扰。
八、验收清单
- 未读 0 时不显示角标(不是显示 0)
- ≥ 100 显示
99+ - 「未读」的定义明确(未查看/未点击),且全站一致
- 进入消息中心后角标清零,列表项也同步变已读态
- 未读数乐观更新,失败回滚
- 每次进入页面从服务端重新同步未读数
- 分类 ≤ 4,且每个分类都有量(否则不分 Tab)
- 已读/未读的视觉差异不只靠小圆点
- 时间是相对格式,超过一年才显示完整日期
- 批量标记用单接口,不是循环
- 「全部标为已读」有二次确认
- 轮询 60s 且
refetchIntervalInBackground: false - 切回标签页立即刷新一次
- WebSocket 有重连、心跳、去重、补漏
- 多标签页只建一个连接
九、反面清单
- ❌ 未读数为 0 时显示「0」
- ❌ 未读数显示 1000+
- ❌ 进入页面标了已读,但列表项还是未读态
- ❌ 未读数只在登录时拿一次,之后不更新
- ❌ 后台标签页还在轮询
- ❌ 「全部已读」没有二次确认
- ❌ 批量已读循环调单条接口
- ❌ 消息列表显示精确到秒的时间
- ❌ 5 个标签页建 5 个 WebSocket
- ❌ 免打扰就真的不存消息了
- ❌ 点击消息后角标不立即变(等接口返回)
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

