记录选项卡(详情页内 tab 分区 + 懒加载)
详情页把一个记录切成几张 tab(概览 / 活动 / 设置…),当前 tab 存在 URL 里,链接可分享、后退键能回;每张 tab 的数据首次切到才拉,切回不再取数。资源有多个视角、不适合塞进一屏时,就是它。
试这几下:
- 点概览 / 活动 / 设置 —— URL hash 立刻变成
#tab=<key>;tab 标签后面出「•」代表已缓存 - 首次切到某 tab —— 出骨架屏 ~400ms 后展示;再切回,秒开(缓存命中,不重复取数)
- 设置里翻两个开关再点保存设置 —— toast + 写回缓存;下次切回值仍在
- 设置里点取消订单 —— 先弹确认;确认后 toast,订单头状态角标变「已取消」
- 直接在浏览器地址栏改 hash(或刷新 iframe)—— 页面就落到该 tab
规矩
- 当前 tab 在 URL 里(分享/后退/前进可用),不是局部 state。demo 用
location.hash(#tab=…)模拟;真实版走嵌套路由/<resource>/$id/<tab>(TanStack Router 的createFileRoute+validateSearch) - 每个 tab 首次切到才拉数据(懒加载),加载中出骨架屏;base-ui
Tabs会挂载所有面板,所以每个 panel 各自的查询要用当前 tab 门控,未选中时不发请求 - 已加载过的 tab 缓存不重复取数。demo 用
useState存{ [tab]: data };真实版按 TanStack Query 查询键["<resource>", "detail", id, "<tab>"]天然缓存,切回是纯读缓存 - 变更必 toast(保存设置 / 取消订单 均有),破坏性操作先弹确认(取消订单);真实版取消订单成功后失效
["<resource>","detail",id]查询键,让概览重取
蓝本:
add-record-tabs.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-record-tabs.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-record-tabs.html —— 「记录选项卡」形状的最小可玩实现(蓝本:open-dashboard 的 add-record-tabs)。
复现的不变量(+ demo 与真实版的差异):
- 当前 tab 在 URL 里 —— 分享/后退可用。demo 用 location.hash(#tab=…)模拟;真实版走嵌套路由 /orders/$id/<tab>
- 每个 tab 首次切到才拉数据,出骨架屏 ~400ms 后展示内容
- 已加载过的 tab 缓存不重复取数。demo 用 useState 缓存;真实版按 TanStack Query 查询键 ["orders","detail",id,"<tab>"] 缓存
- 变更(保存设置 / 取消订单)必 toast;破坏性操作(取消订单)先弹确认
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 12px 0 0; }
.tab { position: relative; border: 0; background: transparent; padding: 8px 14px;
color: var(--muted); cursor: pointer; font: inherit;
border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tab.on { color: var(--fg); border-bottom-color: var(--primary); font-weight: 600; }
.tab:hover:not(.on) { color: var(--fg); }
.tab .dot { color: var(--primary); margin-left: 4px; font-size: 14px; line-height: 1; }
.urlbar { color: var(--muted); font-size: 12px; margin: 6px 0 10px; }
.urlbar code { background: var(--card); border: 1px solid var(--border);
border-radius: 4px; padding: 1px 6px; font-size: 12px; }
.panel { background: var(--card); border: 1px solid var(--border); border-radius: 10px;
padding: 14px 16px; min-height: 240px; }
.panel > .skel { margin: 10px 0; }
.dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 20px; margin: 0; }
.dl > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.dl dt { color: var(--muted); font-size: 12px; margin: 0; }
.dl dd { margin: 0; font-weight: 550; }
.note { margin: 14px 0 0; padding-top: 10px; border-top: 1px dashed var(--border);
font-size: 13px; color: var(--muted); }
.note strong { color: var(--fg); }
.timeline { list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 12px; }
.timeline li { display: flex; gap: 10px; align-items: flex-start; }
.timeline .ico { flex: 0 0 28px; height: 28px; border-radius: 6px;
background: var(--chip-off-bg); color: var(--chip-off-fg);
display: inline-flex; align-items: center; justify-content: center; font-size: 14px; }
.timeline .ev { margin: 0; }
.timeline .at { margin: 2px 0 0; color: var(--muted); font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.setrow { display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.setrow:first-of-type { padding-top: 2px; }
.setrow:last-of-type { border-bottom: 0; }
.setrow .k { display: block; font-weight: 550; }
.setrow .sub { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
.setrow input[type="checkbox"] { width: 16px; height: 16px; }
.setact { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px;
padding-top: 10px; border-top: 1px dashed var(--border); }
</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, useEffect } = React;
const html = htm.bind(React.createElement);
const ORDER_ID = "ORD-4821";
const TABS = [
{ key: "overview", label: "概览" },
{ key: "activity", label: "活动" },
{ key: "settings", label: "设置" },
];
// 三份服务端数据(真实版对应 3 个 useQuery,按 tab 各自拉)
const OVERVIEW = {
id: ORDER_ID,
customer: "Northwind 实验室",
plan: "旗舰版(年付)",
amount: "¥ 4,800 / 月",
seats: "24 / 40",
region: "华东",
renewal: "2027-04-30",
note: "重点客户;预计 Q3 有扩容评审。主要联系人偏好异步沟通。",
};
const ACTIVITY = [
{ id: "a1", icon: "✎", text: "陈禾 将订阅升级为旗舰版", at: "2026-05-12 09:41" },
{ id: "a2", icon: "ℹ", text: "席位数从 18 上调至 24", at: "2026-05-09 16:08" },
{ id: "a3", icon: "⏱", text: "试用转为付费订阅", at: "2026-04-30 11:22" },
{ id: "a4", icon: "⚙", text: "全员启用 SSO 单点登录", at: "2026-04-18 14:55" },
];
const SETTINGS = { emailDigest: true, autoRenew: false };
// hash <-> tab 双向同步(模拟 URL 里的 tab 段)
function readHashTab() {
const m = /tab=(\w+)/.exec(location.hash);
const t = m && m[1];
return TABS.some(x => x.key === t) ? t : "overview";
}
function writeHashTab(t) { location.hash = "tab=" + t; }
// 模拟服务端往返 320~480ms;真实版对应 useQuery(fetcher) 首次触发
function fetchTab(key) {
const delay = 320 + (key.charCodeAt(0) % 5) * 40;
return new Promise(resolve => setTimeout(() => {
if (key === "overview") resolve(OVERVIEW);
else if (key === "activity") resolve(ACTIVITY);
else resolve({ ...SETTINGS });
}, delay));
}
function Confirm({ text, onOk, onCancel }) {
return html`
<div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onCancel(); }}>
<div class="modal">
<h2>确认操作</h2>
<p style=${{ margin: 0 }}>${text}</p>
<div class="actions">
<button class="btn" onClick=${onCancel}>取消</button>
<button class="btn primary" onClick=${onOk}>确认</button>
</div>
</div>
</div>`;
}
function Skeleton({ rows = 3 }) {
const widths = ["78%", "62%", "88%", "54%"];
return html`<div>${Array.from({ length: rows }, (_, i) => html`
<div class="skel" key=${i} style=${{ width: widths[i % widths.length] }}></div>`)}</div>`;
}
function Overview({ data, status }) {
const rows = [
["订单号", data.id],
["客户", data.customer],
["套餐", data.plan],
["金额", data.amount],
["席位", data.seats],
["区域", data.region],
["到期", data.renewal],
["状态", status],
];
return html`
<div>
<dl class="dl">
${rows.map(([k, v]) => html`<div key=${k}><dt>${k}</dt><dd>${v}</dd></div>`)}
</dl>
<p class="note"><strong>备注:</strong>${data.note}</p>
</div>`;
}
function Activity({ data }) {
return html`
<ul class="timeline">
${data.map(ev => html`
<li key=${ev.id}>
<span class="ico" aria-hidden="true">${ev.icon}</span>
<div>
<p class="ev">${ev.text}</p>
<p class="at">${ev.at}</p>
</div>
</li>`)}
</ul>`;
}
function Settings({ data, onSave, onAskCancel }) {
const [local, setLocal] = useState(data);
const dirty = local.emailDigest !== data.emailDigest || local.autoRenew !== data.autoRenew;
const flip = k => setLocal(s => ({ ...s, [k]: !s[k] }));
return html`
<div>
<label class="setrow">
<div>
<span class="k">每周邮件摘要</span>
<span class="sub">每周一早上发送用量摘要邮件。</span>
</div>
<input type="checkbox" checked=${local.emailDigest} onChange=${() => flip("emailDigest")} />
</label>
<label class="setrow">
<div>
<span class="k">自动续费</span>
<span class="sub">到期时自动扣款续订。</span>
</div>
<input type="checkbox" checked=${local.autoRenew} onChange=${() => flip("autoRenew")} />
</label>
<div class="setact">
<button class="btn" style=${{ color: "var(--danger)" }} onClick=${onAskCancel}>取消订单…</button>
<button class="btn primary" disabled=${!dirty} onClick=${() => onSave(local)}>保存设置</button>
</div>
</div>`;
}
function App() {
const [tab, setTab] = useState(readHashTab);
const [cache, setCache] = useState({}); // { [key]: data | undefined }
const [loading, setLoading] = useState({}); // { [key]: true } 表明进行中
const [status, setStatus] = useState("生效中"); // 订单头状态(取消订单会改)
const [confirm, setConfirm] = useState(null);
const [toast, setToast] = useState("");
// hash <-> state 双向同步(浏览器后退/前进也走这条)
useEffect(() => {
const onHash = () => setTab(readHashTab());
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
useEffect(() => { if (readHashTab() !== tab) writeHashTab(tab); }, [tab]);
// toast 自动消失
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(id);
}, [toast]);
// 懒加载:切到 tab 且未缓存/未在加载时拉一次
useEffect(() => {
if (cache[tab] !== undefined || loading[tab]) return;
setLoading(l => ({ ...l, [tab]: true }));
fetchTab(tab).then(data => {
setCache(c => ({ ...c, [tab]: data }));
setLoading(l => ({ ...l, [tab]: false }));
});
}, [tab, cache, loading]);
const saveSettings = (next) => {
setCache(c => ({ ...c, settings: next }));
setToast("已保存设置");
};
const askCancel = () => setConfirm({
text: `取消订单 ${ORDER_ID}?此操作不可撤销。`,
onOk: () => {
setConfirm(null);
setStatus("已取消");
// 真实版:失效 ["orders","detail",id] 查询键让 overview 重取;demo 直接改本地状态
setToast(`已取消订单 ${ORDER_ID}`);
},
});
const cur = cache[tab];
const isLoading = cur === undefined;
return html`
<div class="page">
<header class="head">
<div>
<h1>订单 ${ORDER_ID}</h1>
<p class="sub">Northwind 实验室 · 每个 tab 首次切到才拉数据;已加载过的秒开;tab 名写进 URL hash,可分享/后退</p>
</div>
<span class=${"chip " + (status === "已取消" ? "chip-off" : "chip-on")}>${status}</span>
</header>
<nav class="tabs" role="tablist" aria-label="订单详情分区">
${TABS.map(t => html`
<button key=${t.key} role="tab" aria-selected=${tab === t.key}
class=${"tab " + (tab === t.key ? "on" : "")}
onClick=${() => setTab(t.key)}>
${t.label}${cache[t.key] !== undefined ? html`<span class="dot" title="已缓存,切回不再取数">•</span>` : null}
</button>`)}
</nav>
<p class="urlbar">当前 URL: <code>/orders/${ORDER_ID}#tab=${tab}</code>(分享此链接 / 按后退键可回到该 tab)</p>
<div class="panel">
${isLoading
? html`<${Skeleton} rows=${tab === "activity" ? 4 : 3} />`
: tab === "overview"
? html`<${Overview} data=${cur} status=${status} />`
: tab === "activity"
? html`<${Activity} data=${cur} />`
: html`<${Settings} data=${cur} onSave=${saveSettings} onAskCancel=${askCancel} />`}
</div>
${confirm && html`<${Confirm} text=${confirm.text} onOk=${confirm.onOk} onCancel=${() => setConfirm(null)} />`}
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>