---
description: "后台里少数需要「活着」的形状：不用刷新页面，指标和事件流自己跳、自己长。数据不断有新条目从服务端来 —— 订单、告警、活动流 —— 就是它。demo 用 setInterval 模拟每 2.5 秒推来一条订单；真实版换 EventSource / WebSocket，UI 绑定不变"
---

# 实时刷新（Live 订阅 + 新增高亮）

后台里少数需要「活着」的形状：不用刷新页面，指标和事件流自己跳、自己长。数据不断有新条目从服务端来 —— 订单、告警、活动流 —— 就是它。demo 用 `setInterval` 模拟每 2.5 秒推来一条订单；真实版换 `EventSource` / `WebSocket`，UI 绑定不变。

<iframe src="/skills/dashboard/assets/demo-realtime.html"
        style="width:100%;height:640px;border:1px solid #8884;border-radius:10px"
        loading="lazy" title="形状 demo：实时刷新"></iframe>

试这几下：

- 顶部**绿灯 + 「已连接」**在呼吸 —— 每 2.5 秒一条新订单从列表顶部进入
- 新行进入时**背景短暂高亮**（1.6s 淡出），眼睛能盯住变化点
- 点**暂停** —— 灯变灰 + 「已暂停」，新条目立刻停止流入；点**恢复**再接回
- **手动注入一条**不依赖轮询节奏，用户显式动作还带 toast；列表最多保留 20 条，超出的旧行自动丢弃

## 规矩

- **实时是"取数节奏"的差异，不改数据层**：demo 用 `setInterval`；真实版换成 `refetchInterval` 轮询、或 `EventSource` / `WebSocket` 推送。数据层的查询函数、Repository、query key 都不动，只是新事件走 `queryClient.setQueryData` 写回同一份缓存
- **连接状态必须显式**：绿灯 + 「已连接」/ 灰灯 + 「已暂停」，最好再挂一个「Ns 前更新」标签，让人看得出订阅还活着还是已经死了。灯没了、时间戳不动，就是最响的报警
- **暂停要真的停订阅**：真实版 `refetchInterval: false` 或 `es.close()` / `ws.close()`，不能只是把 UI 藏起来还偷偷占带宽；暂停/恢复状态一般走局部 state，如果该视图要分享 URL 才升到 query string
- **新数据合并进现有缓存，别整体覆盖**：`setQueryData((prev) => merge(prev, evt))` 前插到列表，不 `refetch` 整页 —— 用户正在编辑的表单、正在看的滚动位置、正在选中的行，都不能被一次刷新打断
- **离线要能重连回补**：`EventSource` 断线由浏览器自动重连；`WebSocket` 手动重连时带上 `Last-Event-Id` 或最后一条的时间戳，服务端补发这段的增量，避免掉事件

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

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

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

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