表格(CRUD 列表 + 批量操作)
后台的地基形状:一屏列出资源的行,服务端分页/排序/搜索/筛选,勾选多行后出现上下文操作条做批量变更。数据要成行管理、要增删改查,就是它。
试这几下:
- 点姓名 / 加入日期表头 —— 排序方向循环切换
- 勾选几行(或表头全选)—— 批量操作条出现,显示精确选中数;翻页后选择不丢
- 批量删除会先弹确认;任何批量操作完成后 toast + 自动清空选择
- 搜索、状态筛选、分页组合使用,空结果有空态行
规矩
- 列表参数一个形状:
{ page, pageSize, search?, sortBy?, sortDir?, filters? }→{ rows, total }。demo 在内存里过滤排序,真实版把同一份参数交给Repository.list()(服务端分页) - 选择是瞬态 UI 状态:放局部 state,不进 URL;靠稳定的行 id(
getRowId)让选择跨页存活。列表/排序/筛选状态则相反 —— 真实版进 URL - 操作条只在选中 ≥1 行时出现,作用于精确的 id 集;成功后清空选择 + toast;破坏性操作先过确认弹窗
- 真实版的批量变更走一次批量调用(或
Promise.all),成功后失效该资源的查询键
蓝本:
add-bulk-actions.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-table.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:表格(CRUD 列表 + 批量操作)</title>
<!--
demo-table.html —— 「表格」形状的最小可玩实现(蓝本:open-dashboard 的 DataTable 契约 + add-bulk-actions)。
复现的不变量:
- 列表参数一个形状:{ page, pageSize, search, filters(status), sortBy, sortDir } → { rows, total }
- 选择是瞬态 UI 状态(局部 state,不进 URL);行 id 稳定,翻页后选择仍在
- 批量条只在选中 ≥1 行时出现,作用于精确的选中 id 集;操作成功后清空选择 + toast
- 破坏性批量操作先过确认弹窗
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
.arrow { font-size: 10px; }
</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);
const PAGE_SIZE = 6;
const SEED = [
{ id: 1, name: "陈禾", email: "chenhe@corp.cn", plan: "专业版", joined: "2026-03-14", active: true },
{ id: 2, name: "李慕白", email: "mubai@corp.cn", plan: "免费版", joined: "2026-05-02", active: true },
{ id: 3, name: "王小满", email: "xiaoman@corp.cn", plan: "团队版", joined: "2025-11-20", active: false },
{ id: 4, name: "赵青", email: "zhaoqing@corp.cn", plan: "专业版", joined: "2026-01-08", active: true },
{ id: 5, name: "周芷", email: "zhouzhi@corp.cn", plan: "免费版", joined: "2026-06-17", active: true },
{ id: 6, name: "吴限", email: "wuxian@corp.cn", plan: "团队版", joined: "2025-09-30", active: true },
{ id: 7, name: "郑舟", email: "zhengzhou@corp.cn", plan: "专业版", joined: "2026-02-25", active: false },
{ id: 8, name: "冯霁", email: "fengji@corp.cn", plan: "免费版", joined: "2026-04-11", active: true },
{ id: 9, name: "褚遂", email: "chusui@corp.cn", plan: "团队版", joined: "2026-05-29", active: true },
{ id: 10, name: "卫疏", email: "weishu@corp.cn", plan: "专业版", joined: "2025-12-05", active: false },
{ id: 11, name: "蒋声", email: "jiangsheng@corp.cn",plan: "免费版", joined: "2026-06-01", active: true },
{ id: 12, name: "沈砚", email: "shenyan@corp.cn", plan: "团队版", joined: "2026-03-03", active: true },
{ id: 13, name: "韩澈", email: "hanche@corp.cn", plan: "专业版", joined: "2026-01-22", active: true },
{ id: 14, name: "杨湜", email: "yangshi@corp.cn", plan: "免费版", joined: "2025-10-18", active: false },
];
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 App() {
const [rows, setRows] = useState(SEED);
const [q, setQ] = useState("");
const [status, setStatus] = useState("全部");
const [sort, setSort] = useState({ by: "joined", dir: "desc" });
const [page, setPage] = useState(1);
const [sel, setSel] = useState({}); // 选择:瞬态局部 state,key 为稳定 id
const [confirm, setConfirm] = useState(null); // { text, onOk }
const [toast, setToast] = useState("");
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
const filtered = useMemo(() => {
const kw = q.trim().toLowerCase();
const list = rows.filter(r =>
(status === "全部" || (status === "活跃") === r.active) &&
(r.name.toLowerCase().includes(kw) || r.email.toLowerCase().includes(kw)));
const mul = sort.dir === "asc" ? 1 : -1;
return [...list].sort((a, b) => (a[sort.by] < b[sort.by] ? -1 : a[sort.by] > b[sort.by] ? 1 : 0) * mul);
}, [rows, q, status, sort]);
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const cur = Math.min(page, pages);
const view = filtered.slice((cur - 1) * PAGE_SIZE, cur * PAGE_SIZE);
const selIds = Object.keys(sel).filter(k => sel[k]).map(Number);
const pageAllChecked = view.length > 0 && view.every(r => sel[r.id]);
const toggleSort = by =>
setSort(s => ({ by, dir: s.by === by && s.dir === "asc" ? "desc" : "asc" }));
const togglePageAll = () => {
const next = { ...sel };
view.forEach(r => { next[r.id] = !pageAllChecked; });
setSel(next);
};
const applyBulk = (label, fn) => {
setRows(rs => rs.map(r => (selIds.includes(r.id) ? fn(r) : r)).filter(Boolean));
setToast(`已${label} ${selIds.length} 名成员`);
setSel({});
};
const SortTh = ({ by, children }) => html`
<th class="sortable" onClick=${() => toggleSort(by)}>
${children} <span class="arrow">${sort.by === by ? (sort.dir === "asc" ? "▲" : "▼") : ""}</span>
</th>`;
return html`
<div class="page">
<header class="head">
<div>
<h1>成员管理</h1>
<p class="sub">搜索 / 筛选 / 点表头排序 / 勾选行出现批量操作条</p>
</div>
</header>
<div class="toolbar">
<input class="grow" placeholder="搜索姓名或邮箱…" value=${q}
onChange=${e => { setQ(e.target.value); setPage(1); }} />
<select value=${status} onChange=${e => { setStatus(e.target.value); setPage(1); }}>
<option>全部</option><option>活跃</option><option>停用</option>
</select>
</div>
${selIds.length > 0 && html`
<div class="selbar">
<span>已选 <span class="count">${selIds.length}</span> 项</span>
<button class="btn sm" onClick=${() => applyBulk("启用", r => ({ ...r, active: true }))}>批量启用</button>
<button class="btn sm" onClick=${() => applyBulk("停用", r => ({ ...r, active: false }))}>批量停用</button>
<button class="btn sm" style=${{ color: "var(--danger)" }}
onClick=${() => setConfirm({
text: `删除选中的 ${selIds.length} 名成员?此操作不可撤销。`,
onOk: () => { applyBulk("删除", () => null); setConfirm(null); },
})}>批量删除</button>
<button class="link" onClick=${() => setSel({})}>取消选择</button>
</div>`}
<table class="list">
<thead>
<tr>
<th><input type="checkbox" aria-label="全选本页" checked=${pageAllChecked} onChange=${togglePageAll} /></th>
<${SortTh} by="name">姓名<//>
<th>邮箱</th>
<th>套餐</th>
<${SortTh} by="joined">加入日期<//>
<th>状态</th>
</tr>
</thead>
<tbody>
${view.map(r => html`
<tr key=${r.id}>
<td><input type="checkbox" aria-label=${"选择 " + r.name} checked=${!!sel[r.id]}
onChange=${() => setSel({ ...sel, [r.id]: !sel[r.id] })} /></td>
<td style=${{ fontWeight: 550 }}>${r.name}</td>
<td>${r.email}</td>
<td>${r.plan}</td>
<td class="num">${r.joined}</td>
<td><span class=${"chip " + (r.active ? "chip-on" : "chip-off")}>${r.active ? "活跃" : "停用"}</span></td>
</tr>`)}
${view.length === 0 && html`<tr><td class="empty" colSpan="6">没有匹配的成员</td></tr>`}
</tbody>
</table>
<footer class="pager">
<span>共 ${filtered.length} 名 · 第 ${cur} / ${pages} 页 · 选择跨页保留</span>
<div>
<button class="btn" disabled=${cur <= 1} onClick=${() => setPage(cur - 1)}>上一页</button>
<button class="btn" disabled=${cur >= pages} onClick=${() => setPage(cur + 1)}>下一页</button>
</div>
</footer>
${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>