行内编辑(单元格原地改)
表格里的轻量修改入口:点击单元格,静态值原地换成输入框或下拉,键盘提交/撤销,改完 toast 报告。改个名、调个库存、切个状态——不值得开编辑弹窗的小修,就是它。
试这几下:
- 点商品名 —— 原地变输入框,Enter / 点别处提交,Esc 撤销恢复原值;编辑中再点别的格,前一个先结算
- 点库存填
1200或字母 —— 范围校验拒绝提交、就地报错;改成0会被「服务端」拒绝:值先乐观生效,随后回滚 + 错误 toast - 点状态 —— 原地变下拉,选中即提交
- 点 SKU —— 点不动。可编辑列是显式声明的,不是每列都该可点
规矩
- 同一时刻只编辑一个单元格:编辑态就是一个
{ id, field },开新格之前旧格必已提交或撤销 - 提交/撤销键位固定:文本/数字列 Enter、失焦提交,Esc 撤销恢复原值;下拉列选中即提交。别发明第四种手势
- 可编辑列显式声明:逐列指定编辑控件(文本 → 输入框、枚举 → 下拉),别顺手把整表做成可点
- 先校验、再提交、必 toast:demo 在内存里乐观更新 +
setTimeout假服务端,失败把该字段回滚到原值;真实版把提交换成Repository的update(id, { [field]: value })mutation,成功后失效该资源的查询键,失败同样回滚 + toast
蓝本:
add-inline-edit.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-inline-edit.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-inline-edit.html —— 「行内编辑」形状的最小可玩实现(蓝本:open-dashboard 的 add-inline-edit + table-inline-edit.tsx)。
复现的不变量:
- 同一时刻只有一个单元格处于编辑态(editing = { id, field });点击单元格原地换成输入框/下拉
- 文本/数字列 Enter 或失焦提交、Esc 撤销恢复原值;下拉列选中即提交
- 可编辑列是显式声明的(商品名/库存/状态),SKU 列刻意点不动作对照
- 提交前先校验(商品名非空、库存 0–999 整数),非法值拒绝提交并就地报错;每次提交都有 toast;
乐观更新 + 假服务端(库存直接改 0 会被拒绝)→ 失败回滚该字段 + 错误 toast
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.cellbtn { display: block; width: 100%; border: 0; background: none; font: inherit; color: inherit;
text-align: left; cursor: pointer; padding: 2px 6px; margin: -2px -6px; border-radius: 6px; }
.cellbtn:hover { background: var(--bg); box-shadow: inset 0 0 0 1px var(--border); }
.cellbtn.num-btn { text-align: right; }
.edit-input, .edit-select { width: 100%; padding: 3px 8px; font-size: 13px; }
.edit-input.invalid { border-color: var(--danger); }
.edit-input.num { text-align: right; }
.cell-err { margin: 2px 0 0; color: var(--danger); font-size: 12px; }
.saving { color: var(--muted); font-size: 12px; margin-left: 6px; }
.hint { margin-top: 10px; color: var(--muted); font-size: 13px; }
.toast.error { background: var(--danger); color: #fff; }
</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, useRef } = React;
const html = htm.bind(React.createElement);
const SEED = [
{ id: 1, name: "青瓷马克杯", sku: "CU-1024", stock: 42, status: "在售" },
{ id: 2, name: "胡桃木托盘", sku: "TR-0311", stock: 7, status: "在售" },
{ id: 3, name: "亚麻桌旗", sku: "TB-2201", stock: 3, status: "缺货" },
{ id: 4, name: "黄铜书签", sku: "BK-0977", stock: 156, status: "在售" },
{ id: 5, name: "手工香薰蜡烛", sku: "CA-4408", stock: 23, status: "下架" },
{ id: 6, name: "再生纸笔记本", sku: "NB-1783", stock: 88, status: "在售" },
];
const STATUS = ["在售", "缺货", "下架"];
const STATUS_CHIP = { "在售": "chip-on", "缺货": "chip-warn", "下架": "chip-off" };
// 可编辑列显式声明:不在这张表里的列(如 SKU)就是不可编辑
const FIELDS = {
name: {
label: "商品名",
parse: v => { const t = v.trim(); return t ? { value: t } : { err: "商品名不能为空" }; },
},
stock: {
label: "库存",
parse: v => {
const t = v.trim();
if (!/^\d+$/.test(t) || Number(t) > 999) return { err: "库存需为 0–999 的整数" };
return { value: Number(t) };
},
},
};
// 假服务端:库存直接改为 0 会被业务规则拒绝,其余 700ms 后成功
function fakeUpdate(id, field, value) {
return new Promise((resolve, reject) => setTimeout(() => {
if (field === "stock" && value === 0) reject(new Error("库存不能直接改为 0(请先下架商品)"));
else resolve();
}, 700));
}
function App() {
const [rows, setRows] = useState(SEED);
const [editing, setEditing] = useState(null); // { id, field }:同一时刻只有一个单元格在编辑态
const editingRef = useRef(null); // 供 blur 回调判断编辑态是否仍然有效(Esc/Enter 后 blur 不重复提交)
const [draft, setDraft] = useState("");
const [err, setErr] = useState(""); // 当前编辑格的就地校验错误
const [pending, setPending] = useState({}); // { "id:field": true }:乐观更新等待假服务端确认
const [toast, setToast] = useState(null); // { text, kind }
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2600);
return () => clearTimeout(t);
}, [toast]);
const keyOf = (id, field) => id + ":" + field;
const isEditing = (id, field) => editing && editing.id === id && editing.field === field;
const setEditingCell = cell => { editingRef.current = cell; setEditing(cell); };
const startEdit = (row, field) => {
if (pending[keyOf(row.id, field)]) return; // 该格保存中,先别叠一层编辑
setEditingCell({ id: row.id, field });
setDraft(String(row[field]));
setErr("");
};
const cancelEdit = () => { setEditingCell(null); setErr(""); };
// 乐观更新:先改本地,再打假服务端;失败把该字段回滚到原值 + 错误 toast
const mutate = (row, field, value, label) => {
const prev = row[field];
const key = keyOf(row.id, field);
setRows(rs => rs.map(r => (r.id === row.id ? { ...r, [field]: value } : r)));
setPending(p => ({ ...p, [key]: true }));
fakeUpdate(row.id, field, value).then(
() => {
setPending(p => { const n = { ...p }; delete n[key]; return n; });
setToast({ text: `已更新「${field === "name" ? value : row.name}」的${label}`, kind: "ok" });
},
e => {
setRows(rs => rs.map(r => (r.id === row.id ? { ...r, [field]: prev } : r)));
setPending(p => { const n = { ...p }; delete n[key]; return n; });
setToast({ text: `保存失败:${e.message},已回滚`, kind: "error" });
});
};
// 文本/数字列的提交:先校验;Enter 遇非法值就地报错留在编辑态,失焦遇非法值恢复原值 + 提示
const commitDraft = (row, field, fromBlur) => {
const cur = editingRef.current;
if (!cur || cur.id !== row.id || cur.field !== field) return;
const res = FIELDS[field].parse(draft);
if (res.err) {
if (fromBlur) { cancelEdit(); setToast({ text: `未提交:${res.err},已恢复原值`, kind: "error" }); }
else setErr(res.err);
return;
}
cancelEdit();
if (res.value === row[field]) return; // 没变化就不打服务端
mutate(row, field, res.value, FIELDS[field].label);
};
// 下面三个是普通函数(不是组件):直接内联 html 片段,避免每次渲染换组件身份导致输入框重挂丢焦点
const saving = (row, field) =>
pending[keyOf(row.id, field)] ? html`<span class="saving">保存中…</span>` : null;
const textCell = (row, field, num) => isEditing(row.id, field)
? html`
<div>
<input class=${"edit-input" + (num ? " num" : "") + (err ? " invalid" : "")}
autoFocus value=${draft} inputMode=${num ? "numeric" : undefined}
aria-label=${"编辑" + FIELDS[field].label}
onChange=${e => { setDraft(e.target.value); setErr(""); }}
onKeyDown=${e => {
if (e.key === "Enter") commitDraft(row, field, false);
if (e.key === "Escape") cancelEdit();
}}
onBlur=${() => commitDraft(row, field, true)} />
${err && html`<p class="cell-err">${err}</p>`}
</div>`
: html`
<button class=${"cellbtn" + (num ? " num-btn" : "")} title="点击编辑"
onClick=${() => startEdit(row, field)}>
<span style=${field === "name" ? { fontWeight: 550 } : null}>${row[field]}</span>
${saving(row, field)}
</button>`;
const statusCell = row => isEditing(row.id, "status")
? html`
<select class="edit-select" autoFocus value=${row.status} aria-label="编辑状态"
onChange=${e => {
const v = e.target.value;
cancelEdit();
if (v !== row.status) mutate(row, "status", v, "状态");
}}
onKeyDown=${e => { if (e.key === "Escape") cancelEdit(); }}
onBlur=${cancelEdit}>
${STATUS.map(s => html`<option key=${s} value=${s}>${s}</option>`)}
</select>`
: html`
<button class="cellbtn" title="点击编辑" onClick=${() => startEdit(row, "status")}>
<span class=${"chip " + STATUS_CHIP[row.status]}>${row.status}</span>
${saving(row, "status")}
</button>`;
return html`
<div class="page">
<header class="head">
<div>
<h1>商品库存</h1>
<p class="sub">点击「商品名 / 库存 / 状态」单元格原地编辑;SKU 列不可编辑</p>
</div>
</header>
<table class="list">
<thead>
<tr>
<th style=${{ width: "44px" }}>#</th>
<th>商品名</th>
<th>SKU</th>
<th class="num">库存</th>
<th>状态</th>
</tr>
</thead>
<tbody>
${rows.map(row => html`
<tr key=${row.id}>
<td class="num" style=${{ color: "var(--muted)" }}>${row.id}</td>
<td>${textCell(row, "name", false)}</td>
<td style=${{ color: "var(--muted)" }}>${row.sku}</td>
<td class="num">${textCell(row, "stock", true)}</td>
<td>${statusCell(row)}</td>
</tr>`)}
</tbody>
</table>
<p class="hint">
校验:商品名非空、库存 0–999 整数,非法值拒绝提交;「服务端」规则:库存不能直接改为
0 —— 提交先乐观生效、随后被拒绝回滚,演示乐观更新。
</p>
${toast && html`<div class=${"toast" + (toast.kind === "error" ? " error" : "")}>${toast.text}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>