跳转至

时间线(活动流)

一屏看清「谁 · 做了什么 · 什么时候」。逆时序排列、按天成组,左侧一条竖线串起圆点节点,右侧卡片挂事件正文。审计日志、项目动态、工单回溯都是它。

试这几下:

  • 顶部类型筛选取消勾选「评论」—— 相关事件消失,若某天全被筛掉,整组也跟着隐藏
  • 时间显示分三档:今天是相对时间(X 分钟/小时前)、昨天带「昨天 HH:MM」、更早退到 MM-DD HH:MM
  • 底部加载更早 —— 出骨架屏后追加更旧记录 + toast;两批到底后按钮消失,出「已到起点」结束态
  • 把所有类型全取消 —— 显式提示「请至少勾选一个类型」,不留空白页

规矩

  • 恒逆时序 + 按天分组:每组一个日期标题(今天 / 昨天 / MM-DD),组内也按时间从新到旧。demo 用固定 NOW 锚点计算分组;真实版按 createdAt desc 从服务端取,前端按天 bucket
  • 圆点连线纯装饰,语义走列表:分组标题下用 <ol>/<li>,圆点/图标带 aria-hidden,屏幕阅读器读到的是「N 项事件」而非「装饰图」
  • 事件一个形状{ id, type, actor, action, at, meta?, icon, tone };tone 用主题令牌(primary / success / muted / warn)映射颜色,不硬编码 hex
  • 分页拉更早:demo 是内存里两批模拟游标;真实版按 add-infinite-list 走服务端游标 —— WHERE createdAt < :cursor ORDER BY createdAt DESC LIMIT N,长历史再套虚拟滚动
  • 相对时间要稳定:demo 用简易 formatter + 固定 NOW;真实版建议 Intl.RelativeTimeFormat + useEffect 定时刷新分钟级差,并把「精确 HH:MM」放进 <time title> 或 hover tooltip

蓝本:add-timeline.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-timeline.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:时间线</title>
<!--
  demo-timeline.html —— 「时间线(活动流)」形状的最小可玩实现(蓝本:open-dashboard 的 add-timeline.md + templates/timeline.tsx)。
  复现的不变量:
  - 逆时序、按天分组:每组一个日期标题(今天 / 昨天 / MM-DD),组内按时间从新到旧
  - 每条事件 = 左侧连线上的一枚圆点(icon + tone)+ 右侧「actor + action + time」;
    连线与圆点仅装饰(aria-hidden),语义走 <ol>/<li>,屏幕阅读器读到的是「N 项事件」
  - 时间显示:今天用相对时间(X 分钟/小时前)、昨天带「昨天 HH:MM」、更早退到 MM-DD HH:MM
  - 顶部类型多选筛选:勾选/取消对应类型;筛掉后空组自动隐藏;全清后显式提示
  - 底部「加载更早」按游标模拟异步:出骨架屏 → 追加更旧记录 → 到底后按钮消失出结束态
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  /* 分组日期标题 */
  .day-label { margin: 14px 0 10px; color: var(--muted); font-size: 12px;
               font-weight: 550; letter-spacing: .04em; text-transform: uppercase; }
  .day-label:first-of-type { margin-top: 4px; }
  /* 时间线主轴:ol 的左 border + li 相对定位 + 圆点绝对定位到轴上 */
  .timeline { list-style: none; margin: 0 0 6px; padding: 0 0 0 32px;
              position: relative; border-left: 1px solid var(--border); }
  .timeline li { position: relative; padding: 0 0 14px; }
  .timeline li:last-child { padding-bottom: 4px; }
  .dot { position: absolute; left: -45px; top: 2px; width: 26px; height: 26px;
         border-radius: 999px; display: flex; align-items: center; justify-content: center;
         box-shadow: 0 0 0 4px var(--bg); }
  .dot svg { width: 14px; height: 14px; }
  .dot.t-create  { background: color-mix(in srgb, var(--primary) 15%, transparent); color: var(--primary); }
  .dot.t-comment { background: var(--chip-off-bg); color: var(--chip-off-fg); }
  .dot.t-status  { background: var(--chip-on-bg);  color: var(--chip-on-fg); }
  .dot.t-upload  { background: var(--chip-warn-bg); color: var(--chip-warn-fg); }
  /* 事件卡片 */
  .ev-card { background: var(--card); border: 1px solid var(--border); border-radius: 10px;
             padding: 8px 12px; }
  .ev-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
  .ev-head p { margin: 0; line-height: 1.55; }
  .ev-actor { font-weight: 600; }
  .ev-action { color: var(--muted); }
  .ev-time { flex: none; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
  .ev-meta { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
  /* 类型多选筛选:圆角胶囊,勾选/取消视觉分明 */
  .filters { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 10px; }
  .filters .lab { color: var(--muted); font-size: 12px; }
  .filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
                 border: 1px solid var(--border); border-radius: 999px; font-size: 12px;
                 cursor: pointer; user-select: none; background: var(--card); color: var(--muted); }
  .filter-chip input { margin: 0; }
  .filter-chip.on { border-color: var(--primary); color: var(--fg); }
  /* 加载更早 + 结束态 + 骨架 */
  .earlier { display: flex; justify-content: center; padding: 8px 0 2px; }
  .end { color: var(--muted); font-size: 13px; text-align: center; padding: 10px 0 2px; }
  .skel-wrap { padding: 4px 0 2px; }
  .skel-wrap .skel { margin: 8px 0 0 32px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect } = React;
const html = htm.bind(React.createElement);

// —— 「现在」锚点:所有相对时间都以这个固定时刻为参照,demo 在任何时刻打开都稳定 ——
const NOW = new Date("2026-07-07T14:00:00");

// —— 事件类型元数据:图标 + 中文名 + tone key(映射到 .dot.t-*)——
const TYPES = {
  create: {
    key: "create", label: "创建", tone: "t-create",
    icon: html`<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M10 4v12M4 10h12"/></svg>`,
  },
  comment: {
    key: "comment", label: "评论", tone: "t-comment",
    icon: html`<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"><path d="M3 5h14v9H8l-4 3v-3H3z"/></svg>`,
  },
  status: {
    key: "status", label: "状态变更", tone: "t-status",
    icon: html`<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M4 10.5l3.5 3.5L16 5.5"/></svg>`,
  },
  upload: {
    key: "upload", label: "上传附件", tone: "t-upload",
    icon: html`<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 14V4M6 8l4-4 4 4M4 15v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-1"/></svg>`,
  },
};

// 事件构造:hoursAgo 是相对 NOW 往回的小时数(可为浮点)
function ev(id, type, actor, action, hoursAgo, meta) {
  return { id, type, actor, action, at: new Date(NOW.getTime() - hoursAgo * 3600 * 1000), meta: meta || null };
}

// 首屏一批(今天 + 昨天)
const RECENT = [
  ev("e1", "status",  "陈禾",   "把工单 #482 标为已解决",           0.15),
  ev("e2", "upload",  "李慕白", "上传附件 设计评审-v3.pdf",         0.9,  "1.4 MB · PDF"),
  ev("e3", "comment", "王小满", "在《空态审计》中留下 2 条评论",     1.7),
  ev("e4", "create",  "赵青",   "创建里程碑 v2.4 发布",              3.5),
  ev("e5", "comment", "吴限",   "回复了 @赵青 的评论",              25),
  ev("e6", "status",  "郑舟",   "关闭了工单 #468",                  27.5),
  ev("e7", "create",  "冯霁",   "新建仪表盘 用户增长周报",           31),
];

// 「加载更早」的两批 —— 模拟按游标的服务端分页
const EARLIER_BATCHES = [
  [
    ev("e8",  "upload",  "褚遂", "上传附件 会议纪要-0705.md",       40,  "5.2 KB · Markdown"),
    ev("e9",  "comment", "卫疏", "在 PR #470 上留下评审意见",       49),
    ev("e10", "status",  "沈砚", "把工单 #465 指派给 @韩澈",        59),
    ev("e11", "upload",  "韩澈", "上传附件 用户访谈-U07.mp3",        73, "12.4 MB · Audio"),
    ev("e12", "create",  "杨湜", "创建 API Token dashboard-write",   78),
  ],
  [
    ev("e13", "comment", "陈禾",   "在《权限模型 v2》讨论线程中评论",   88),
    ev("e14", "status",  "李慕白", "将功能 flag「新版首页」切到 100%", 100),
    ev("e15", "create",  "王小满", "创建规范文档 组件间距 8pt 网格",   106),
  ],
];

// —— 相对时间格式化(简易版)——
// 真实版可用 Intl.RelativeTimeFormat,并配合 useEffect 定时刷新分钟级差
function formatTime(at, now) {
  const diff = now.getTime() - at.getTime();
  const HH = String(at.getHours()).padStart(2, "0");
  const MM = String(at.getMinutes()).padStart(2, "0");
  const sameDay = at.getFullYear() === now.getFullYear()
               && at.getMonth() === now.getMonth()
               && at.getDate() === now.getDate();
  const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
  const isYest = at.getFullYear() === yesterday.getFullYear()
              && at.getMonth() === yesterday.getMonth()
              && at.getDate() === yesterday.getDate();
  if (sameDay) {
    if (diff < 60000) return "刚刚";
    if (diff < 3600000) return Math.floor(diff / 60000) + " 分钟前";
    return Math.floor(diff / 3600000) + " 小时前";
  }
  if (isYest) return "昨天 " + HH + ":" + MM;
  const mm = String(at.getMonth() + 1).padStart(2, "0");
  const dd = String(at.getDate()).padStart(2, "0");
  return mm + "-" + dd + " " + HH + ":" + MM;
}

// —— 按天分组:key 用 YYYY-MM-DD,label 走 dayLabel ——
function dayKey(at) {
  return at.getFullYear() + "-" +
         String(at.getMonth() + 1).padStart(2, "0") + "-" +
         String(at.getDate()).padStart(2, "0");
}
function dayLabel(k, now) {
  const [y, m, d] = k.split("-").map(Number);
  if (y === now.getFullYear() && m === now.getMonth() + 1 && d === now.getDate()) return "今天";
  const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
  if (y === yest.getFullYear() && m === yest.getMonth() + 1 && d === yest.getDate()) return "昨天";
  return String(m).padStart(2, "0") + "-" + String(d).padStart(2, "0");
}

function App() {
  const [events, setEvents] = useState(RECENT);
  const [batchIdx, setBatchIdx] = useState(0);
  const [loading, setLoading] = useState(false);
  const [types, setTypes] = useState({ create: true, comment: true, status: true, upload: true });
  const [toast, setToast] = useState("");

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(""), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  // 过滤 → 按天分组(保持逆时序,空组会因为 filter 后长度=0 而不进入结果)
  const groups = useMemo(() => {
    const filtered = events.filter(e => types[e.type]);
    const map = new Map();
    for (const e of filtered) {
      const k = dayKey(e.at);
      if (!map.has(k)) map.set(k, []);
      map.get(k).push(e);
    }
    // Map 保持插入顺序,events 本身已按时间降序,所以分组也自然按天降序
    return Array.from(map.entries()).map(([k, list]) => ({
      key: k, label: dayLabel(k, NOW), events: list,
    }));
  }, [events, types]);

  const hasMore = batchIdx < EARLIER_BATCHES.length;
  const loadEarlier = () => {
    if (loading || !hasMore) return;
    setLoading(true);
    setTimeout(() => {
      const batch = EARLIER_BATCHES[batchIdx];
      setEvents(prev => [...prev, ...batch]);
      setBatchIdx(i => i + 1);
      setLoading(false);
      setToast("已追加 " + batch.length + " 条更早记录");
    }, 650);
  };

  const toggleType = k => setTypes(t => ({ ...t, [k]: !t[k] }));
  const activeCount = Object.values(types).filter(Boolean).length;
  const visibleTotal = groups.reduce((s, g) => s + g.events.length, 0);

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>项目活动流</h1>
          <p class="sub">逆时序 · 按天分组 · 顶部按类型多选筛选 · 底部加载更早</p>
        </div>
      </header>

      <div class="filters">
        <span class="lab">类型:</span>
        ${Object.values(TYPES).map(t => html`
          <label key=${t.key} class=${"filter-chip " + (types[t.key] ? "on" : "")}>
            <input type="checkbox" checked=${types[t.key]}
                   onChange=${() => toggleType(t.key)}
                   aria-label=${"切换类型 " + t.label} />
            ${t.label}
          </label>`)}
        <span class="lab" style=${{ marginLeft: "auto" }}>
          可见 ${visibleTotal} / ${events.length} 条 · 已启用 ${activeCount}
        </span>
      </div>

      ${activeCount === 0 && html`
        <div class="empty" style=${{
          background: "var(--card)", border: "1px solid var(--border)", borderRadius: 10,
        }}>请至少勾选一个类型</div>`}

      ${activeCount > 0 && groups.length === 0 && html`
        <div class="empty" style=${{
          background: "var(--card)", border: "1px solid var(--border)", borderRadius: 10,
        }}>当前筛选下没有事件</div>`}

      ${groups.map(g => html`
        <div key=${g.key}>
          <div class="day-label">${g.label}</div>
          <ol class="timeline" aria-label=${g.label + " 的事件列表"}>
            ${g.events.map(e => {
              const t = TYPES[e.type];
              return html`
                <li key=${e.id}>
                  <span class=${"dot " + t.tone} aria-hidden=${true}>${t.icon}</span>
                  <div class="ev-card">
                    <div class="ev-head">
                      <p>
                        <span class="ev-actor">${e.actor}</span>${" "}
                        <span class="ev-action">${e.action}</span>
                      </p>
                      <time class="ev-time" dateTime=${e.at.toISOString()}>${formatTime(e.at, NOW)}</time>
                    </div>
                    ${e.meta && html`<p class="ev-meta">${e.meta}</p>`}
                  </div>
                </li>`;
            })}
          </ol>
        </div>`)}

      ${loading && html`
        <div class="skel-wrap">
          <div class="skel" style=${{ width: "72%" }}></div>
          <div class="skel" style=${{ width: "56%" }}></div>
        </div>`}

      ${!loading && hasMore && groups.length > 0 && html`
        <div class="earlier">
          <button class="btn" onClick=${loadEarlier}>加载更早</button>
        </div>`}

      ${!loading && !hasMore && groups.length > 0 && html`
        <div class="end">✓ 已到时间线起点 · 共 ${events.length} 条</div>`}

      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>