时间线(活动流)
一屏看清「谁 · 做了什么 · 什么时候」。逆时序排列、按天成组,左侧一条竖线串起圆点节点,右侧卡片挂事件正文。审计日志、项目动态、工单回溯都是它。
试这几下:
- 顶部类型筛选取消勾选「评论」—— 相关事件消失,若某天全被筛掉,整组也跟着隐藏
- 时间显示分三档:今天是相对时间(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>