审计日志(活动追踪 + 字段级 diff)
按时间倒序列出「谁在什么时候对什么做了什么」的追加式历史:每条三段——头像+名字(who)、动作+对象(what)、相对时间(when,hover 看绝对时间)——展开还能看每个字段改前改后的红绿 diff。资源要能追溯、要能出锅时对账,就靠它。
试这几下:
- 顶部动作类型多选(创建/更新/删除/状态变更/登录)—— 任意组合,无选中即全部
- 切换时间范围(今天/近 7 天/近 30 天/自定义)—— 自定义会展开两个日期选择器
- 悬浮相对时间看到绝对时间戳(例如「2 分钟前」→「2026-07-07 15:32」)
- 点行尾展开看字段级 diff:改前红色删除线、改后绿色高亮;只有
to的字段(创建)只出绿色,只有from的字段(删除)只出红色 - 组合筛选清空时的空态引导放宽条件;「全部展开 / 收起」批量控制
规矩
- 追加式历史,不改不删:审计行只增,
recordAudit()与资源的create*/update*/delete*服务函数写在同一事务里;requireUser()之后拿 actor,diff 服务端算好一起入库。客户端只渲染,不重写、不回填、不重排 - 反向时间序 + 语义化列表:newest-first,DOM 用
<ol><li>,左侧连线和圆点是纯装饰 - 参数一个形状:
{ resource, resourceId?, types?, from?, to?, page, pageSize }→{ rows, total }。demo 在内存按类型 + 时间范围过滤;真实版把同一份参数交给AuditLogRepository.list()(服务端过滤 + 分页;长历史用无限滚动补齐) tone决定色点,changes决定是否可展开:tone枚举created/updated/deleted/status/login驱动圆点颜色;changes是[{ field, from?, to? }]——创建可省from、删除可省to,前端只按字符串渲染,不解构原始对象(diff 在服务端算好一起返回)- 时间双写:相对时间给一眼扫读,绝对时间挂在
<time datetime>的title里给追溯;服务端存 ISO,前端本地化 - 权限:审计日志通常需要管理员角色 —— 真实版在路由和 server fn 都要
requireRole("admin"),普通用户拿不到这个页面;demo 略去这一层
蓝本:
add-audit-log.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-audit-log.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-audit-log.html —— 「审计日志」形状的最小可玩实现
(蓝本:open-dashboard 的 add-audit-log + templates/AuditTrail.tsx + audit-log.tsx)。
复现的不变量:
- 追加式历史:demo 只读渲染,不做增删改;真实版由 recordAudit() 与 create*/update*/delete* 同事务写入
- 反向时间序:newest-first;结构 <ol><li>… 语义化,连线 + 小圆点为装饰
- 列表参数一个形状:{ types, from, to } → 过滤后的 entries;demo 在内存里过滤,真实版把同参数交给 AuditLogRepository.list()
- tone 决定圆点颜色(created/updated/deleted/status/login),changes 决定条目是否可展开
- diff 是字符串化的 { field, from?, to? } 数组:创建可省 from、删除可省 to;前端只按字符串渲染
- 时间双写:相对时间给扫读,绝对时间挂在 <time title> 里给追溯
- 权限:审计日志通常需要管理员角色,真实版路由 + server fn 都要 requireRole("admin");demo 略去
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.count-line { margin: 6px 2px 10px; font-size: 12px; color: var(--muted); }
.count-line strong { color: var(--fg); }
.pills { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.pill { padding: 4px 11px; border-radius: 999px; border: 1px solid var(--border);
background: var(--card); color: var(--muted); cursor: pointer;
font: inherit; font-size: 12px; }
.pill:hover { border-color: var(--primary); color: var(--fg); }
.pill.on { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.trail { position: relative; list-style: none; padding: 4px 0 4px 32px;
margin: 0; border-left: 1px solid var(--border); }
.entry { position: relative; padding: 12px 4px 12px 4px; }
.entry + .entry { border-top: 1px dashed var(--border); }
.dot { position: absolute; left: -44px; top: 12px; width: 24px; height: 24px;
border-radius: 50%; display: grid; place-items: center;
font-size: 11px; font-weight: 650; color: #fff;
box-shadow: 0 0 0 4px var(--bg); }
.dot.created { background: #22884a; }
.dot.updated { background: var(--primary); color: var(--primary-fg); }
.dot.deleted { background: var(--danger); }
.dot.status { background: #b47a17; }
.dot.login { background: #6a737d; }
.row { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.who { font-weight: 600; }
.what { color: var(--muted); }
.what strong { color: var(--fg); font-weight: 550; }
.when { margin-left: auto; font-size: 12px; color: var(--muted);
font-variant-numeric: tabular-nums; cursor: help;
border-bottom: 1px dotted var(--border); }
.expand { border: 0; background: none; color: var(--primary); padding: 0 4px;
cursor: pointer; font: inherit; font-size: 12px; }
.expand:hover { text-decoration: underline; }
.diff { margin: 8px 0 2px; padding: 6px 0 2px 12px; list-style: none;
border-left: 2px solid var(--border);
display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; }
.diff-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
.diff-field { color: var(--fg); font-weight: 600; }
.diff-old { background: rgba(196, 55, 44, 0.12); color: var(--danger);
text-decoration: line-through; padding: 1px 6px; border-radius: 4px; }
.diff-new { background: rgba(23, 110, 52, 0.14); color: var(--chip-on-fg);
padding: 1px 6px; border-radius: 4px; }
.diff-arrow { color: var(--muted); }
.empty-card { padding: 30px 16px; text-align: center; color: var(--muted);
background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
.date { padding: 5px 8px; }
@media (max-width: 520px) { .when { margin-left: 0; } }
</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-07T15:34:00");
const DAY_MS = 86400000;
const T = (dSec) => new Date(NOW.getTime() + dSec * 1000);
// 历史条目(newest-first)—— 覆盖各 tone:created / updated / deleted / status / login
const ENTRIES = [
{ id: "a-1", actor: "陈禾", tone: "status", action: "变更了状态",
target: "订单 #1043", ts: T(-2 * 60),
changes: [{ field: "状态", from: "备货中", to: "已发货" }] },
{ id: "a-2", actor: "李慕白", tone: "updated", action: "更新了",
target: "商品「云鸣耳机」", ts: T(-18 * 60),
changes: [
{ field: "价格", from: "¥199", to: "¥179" },
{ field: "库存", from: "120", to: "96" },
] },
{ id: "a-3", actor: "系统", tone: "created", action: "创建了",
target: "退款单 #R-88", ts: T(-60 * 60),
changes: [
{ field: "金额", to: "¥42.50" },
{ field: "原因", to: "运输破损" },
] },
{ id: "a-4", actor: "王小满", tone: "deleted", action: "删除了",
target: "优惠券「SPRING20」", ts: T(-3 * 60 * 60),
changes: [{ field: "优惠码", from: "SPRING20" }] },
{ id: "a-5", actor: "赵青", tone: "login", action: "登录了系统",
ts: T(-12 * 60 * 60) },
{ id: "a-6", actor: "陈禾", tone: "created", action: "创建了",
target: "客户「云上科技」", ts: T(-26 * 60 * 60) },
{ id: "a-7", actor: "周芷", tone: "updated", action: "更新了",
target: "订单 #1039", ts: T(-3 * DAY_MS / 1000),
changes: [
{ field: "地址", from: "上海市浦东新区世纪大道 100 号", to: "上海市静安区南京西路 800 号" },
{ field: "备注", to: "客户希望工作日送达" },
] },
{ id: "a-8", actor: "郑舟", tone: "login", action: "登录了系统",
ts: T(-6 * DAY_MS / 1000) },
{ id: "a-9", actor: "冯霁", tone: "deleted", action: "删除了",
target: "商品「过季氛围灯」", ts: T(-14 * DAY_MS / 1000),
changes: [{ field: "SKU", from: "SKU-A007" }] },
{ id: "a-10", actor: "沈砚", tone: "updated", action: "更新了",
target: "用户「陈禾」的角色", ts: T(-21 * DAY_MS / 1000),
changes: [{ field: "角色", from: "编辑", to: "管理员" }] },
];
const ACTION_TYPES = [
{ key: "created", label: "创建" },
{ key: "updated", label: "更新" },
{ key: "deleted", label: "删除" },
{ key: "status", label: "状态变更" },
{ key: "login", label: "登录" },
];
const RANGES = [
{ key: "today", label: "今天" },
{ key: "7d", label: "近 7 天" },
{ key: "30d", label: "近 30 天" },
{ key: "custom", label: "自定义" },
];
const TONE_CN = { created: "创建", updated: "更新", deleted: "删除", status: "状态", login: "登录" };
function pad(n) { return String(n).padStart(2, "0"); }
function absolute(ts) {
return ts.getFullYear() + "-" + pad(ts.getMonth() + 1) + "-" + pad(ts.getDate())
+ " " + pad(ts.getHours()) + ":" + pad(ts.getMinutes());
}
function relative(ts) {
const s = Math.max(0, Math.floor((NOW.getTime() - ts.getTime()) / 1000));
if (s < 60) return s + " 秒前";
if (s < 3600) return Math.floor(s / 60) + " 分钟前";
if (s < 86400) return Math.floor(s / 3600) + " 小时前";
return Math.floor(s / 86400) + " 天前";
}
// 时间范围解算:真实版会把 { from, to } 一起打进 list 查询
function resolveRange(range, from, to) {
const nowMs = NOW.getTime();
if (range === "today") {
const t0 = new Date(NOW); t0.setHours(0, 0, 0, 0);
return { fromMs: t0.getTime(), toMs: nowMs };
}
if (range === "7d") return { fromMs: nowMs - 7 * DAY_MS, toMs: nowMs };
if (range === "30d") return { fromMs: nowMs - 30 * DAY_MS, toMs: nowMs };
// custom
const f = new Date(from + "T00:00:00").getTime();
const t = new Date(to + "T23:59:59").getTime();
return { fromMs: f, toMs: t };
}
// demo 在内存里过滤;真实版:AuditLogRepository.list({ resource, types, from, to, page })
function runList(entries, types, fromMs, toMs) {
return entries.filter(e =>
(types.length === 0 || types.includes(e.tone)) &&
e.ts.getTime() >= fromMs && e.ts.getTime() <= toMs);
}
function DiffList({ changes }) {
return html`
<ul class="diff">
${changes.map((c, i) => html`
<li key=${i} class="diff-row">
<span class="diff-field">${c.field}:</span>
${c.from !== undefined && html`<span class="diff-old">${c.from}</span>`}
${c.from !== undefined && c.to !== undefined && html`<span class="diff-arrow">→</span>`}
${c.to !== undefined && html`<span class="diff-new">${c.to}</span>`}
</li>`)}
</ul>`;
}
function Entry({ e, expanded, onToggle }) {
const has = e.changes && e.changes.length > 0;
return html`
<li class="entry">
<span class=${"dot " + e.tone} aria-label=${TONE_CN[e.tone]}>${e.actor.charAt(0)}</span>
<div class="row">
<span class="who">${e.actor}</span>
<span class="what">
${e.action}${e.target ? html` <strong>${e.target}</strong>` : ""}
</span>
<time class="when" dateTime=${e.ts.toISOString()} title=${absolute(e.ts)}>
${relative(e.ts)}
</time>
${has && html`
<button class="expand" aria-expanded=${expanded ? "true" : "false"} onClick=${onToggle}>
${expanded ? "收起 ▲" : "展开 ▼"}
</button>`}
</div>
${expanded && has && html`<${DiffList} changes=${e.changes} />`}
</li>`;
}
function App() {
const [types, setTypes] = useState([]); // 空数组 = 全部类型
const [range, setRange] = useState("30d");
const [from, setFrom] = useState("2026-06-07");
const [to, setTo] = useState("2026-07-07");
const [expanded, setExpanded] = useState({});
const [toast, setToast] = useState("");
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
const { fromMs, toMs } = useMemo(
() => resolveRange(range, from, to), [range, from, to]);
const filtered = useMemo(
() => runList(ENTRIES, types, fromMs, toMs), [types, fromMs, toMs]);
const toggleType = (k) => {
const next = types.includes(k) ? types.filter(x => x !== k) : [...types, k];
setTypes(next);
setToast("已过滤 · " + runList(ENTRIES, next, fromMs, toMs).length + " 条");
};
const clearTypes = () => { setTypes([]); setToast("已清除类型筛选"); };
const changeRange = (k) => {
setRange(k);
const r = resolveRange(k, from, to);
setToast("「" + RANGES.find(x => x.key === k).label + "」· "
+ runList(ENTRIES, types, r.fromMs, r.toMs).length + " 条");
};
const expandAll = () => {
const m = {};
filtered.forEach(e => { if (e.changes && e.changes.length > 0) m[e.id] = true; });
setExpanded(m);
setToast("已展开全部");
};
const collapseAll = () => { setExpanded({}); setToast("已收起全部"); };
const toggle = (id) => setExpanded(m => ({ ...m, [id]: !m[id] }));
return html`
<div class="page">
<header class="head">
<div>
<h1>审计日志</h1>
<p class="sub">谁在什么时候对什么做了什么 · 追加式历史 · 展开看字段级 diff</p>
</div>
</header>
<div class="toolbar">
<div class="pills" role="group" aria-label="动作类型(多选)">
${ACTION_TYPES.map(a => html`
<button key=${a.key}
class=${"pill " + (types.includes(a.key) ? "on" : "")}
aria-pressed=${types.includes(a.key) ? "true" : "false"}
onClick=${() => toggleType(a.key)}>
${a.label}
</button>`)}
</div>
${types.length > 0 && html`
<button class="link" onClick=${clearTypes}>清除类型</button>`}
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="时间范围">
${RANGES.map(r => html`
<button key=${r.key} class=${range === r.key ? "on" : ""}
onClick=${() => changeRange(r.key)}>${r.label}</button>`)}
</div>
${range === "custom" && html`
<input type="date" class="date" aria-label="起始日期"
value=${from} max=${to}
onChange=${e => setFrom(e.target.value)} />
<span style=${{ color: "var(--muted)" }}>–</span>
<input type="date" class="date" aria-label="截止日期"
value=${to} min=${from}
onChange=${e => setTo(e.target.value)} />`}
<span class="grow"></span>
<button class="btn sm" onClick=${expandAll} disabled=${filtered.length === 0}>全部展开</button>
<button class="btn sm" onClick=${collapseAll}>全部收起</button>
</div>
<p class="count-line">
共 <strong>${filtered.length}</strong> 条 · 最新在最上 · 追加式历史(只增不改不删)
</p>
${filtered.length === 0
? html`<div class="empty-card">
没有匹配的记录 —— 试试放宽时间范围或清除类型筛选
</div>`
: html`
<ol class="trail">
${filtered.map(e => html`
<${Entry} key=${e.id} e=${e}
expanded=${!!expanded[e.id]}
onToggle=${() => toggle(e.id)} />`)}
</ol>`}
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>