向导表单
一份长表单拆成几步填:进度指示 + 分步校验拦住「下一步」+ 最后一步 review 过目再提交。字段多到一屏压迫、或有天然先后(开通流程、建活动、入驻申请)时用它。
试这几下:
- 什么都不填直接点下一步 —— 被当前步校验拦住,错误就地显示
- 填好走到第 2 步再上一步 —— 已填的值一个不丢
- 最后一步是 review:所有值列出来过目,确认才真正提交(成功面板 + toast)
规矩
- 一份表单状态贯穿整个向导(蓝本是一个 TanStack Form 的
useForm),步骤只是它的视图切片 —— 绝不一步一个表单再手工拼合 - 「下一步」被当前步的校验拦住:只校验本步字段(蓝本按 zod
schema.shape[name]逐字段safeParse),错误就地显示 - 最后一步 review 展示全部值;真实版提交走该资源的
Repositorymutation,服务端校验失败把错误回填到表单 - 提交结果必反馈:成功 toast/面板,失败
setServerError
蓝本:
add-wizard-form.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-wizard.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-wizard.html —— 「向导 / 分步表单」形状的最小可玩实现(蓝本:open-dashboard 的 add-wizard-form)。
复现的不变量:
- 一份表单状态贯穿整个向导(蓝本是一个 useForm),步骤只是它的视图切片,来回切换值不丢
- 「下一步」被当前步的校验拦住,错误就地显示;最后一步是 review,提交前把所有值给用户过目
- 提交结果用 toast / 成功面板反馈(真实版走 Repository mutation,失败回填服务端错误)
运行时:/vendor 的 React 18 UMD + htm,样式共用 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.steps { display: flex; gap: 6px; margin: 4px 0 16px; }
.stepdot { flex: 1; text-align: center; font-size: 12px; color: var(--muted); }
.stepdot .bar { height: 4px; border-radius: 2px; background: var(--border); margin-bottom: 6px; }
.stepdot.done .bar, .stepdot.now .bar { background: var(--primary); }
.stepdot.now { color: var(--fg); font-weight: 600; }
.panel { padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
.panel label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.review { margin: 0; }
.review div { display: flex; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid var(--border); }
.review div:last-child { border-bottom: 0; }
.review dt { color: var(--muted); }
.review dd { margin: 0; font-weight: 550; }
.ok { text-align: center; padding: 30px 12px; }
.ok .big { font-size: 34px; }
</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 TYPES = ["新品发布", "促销活动", "品牌联名", "线下快闪"];
const EMPTY = { name: "", type: TYPES[0], start: "", budget: "", note: "" };
// 每步的校验:返回 {字段: 错误文案},空对象 = 通过(蓝本按 zod schema.shape 逐字段 safeParse)
const STEPS = [
{
title: "基本信息",
validate: f => {
const e = {};
if (!f.name.trim()) e.name = "活动名称不能为空";
else if (f.name.trim().length < 4) e.name = "名称至少 4 个字";
return e;
},
},
{
title: "排期与预算",
validate: f => {
const e = {};
if (!f.start) e.start = "选择开始日期";
const b = Number(f.budget);
if (f.budget === "" || !Number.isFinite(b) || b <= 0) e.budget = "预算要是正数";
return e;
},
},
{ title: "确认提交", validate: () => ({}) },
];
function App() {
const [f, setF] = useState(EMPTY); // 一份表单状态贯穿所有步骤
const [step, setStep] = useState(0);
const [errs, setErrs] = useState({});
const [doneData, setDoneData] = useState(null);
const [toast, setToast] = useState("");
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
const set = k => e => setF({ ...f, [k]: e.target.value });
const next = () => {
const e = STEPS[step].validate(f);
setErrs(e);
if (Object.keys(e).length === 0) setStep(s => s + 1);
};
const back = () => { setErrs({}); setStep(s => s - 1); };
const submit = () => { // 真实版:调 Repository mutation,失败 setServerError
setDoneData(f);
setToast(`活动「${f.name.trim()}」已创建`);
};
const reset = () => { setF(EMPTY); setStep(0); setErrs({}); setDoneData(null); };
const Err = ({ k }) => (errs[k] ? html`<p class="err">${errs[k]}</p>` : null);
if (doneData) {
return html`
<div class="page">
<div class="card ok">
<div class="big">✅</div>
<h1>已创建「${doneData.name.trim()}」</h1>
<p class="sub">${doneData.type} · ${doneData.start} 开始 · 预算 ¥ ${Number(doneData.budget).toLocaleString("zh-CN")}</p>
<p><button class="btn primary" onClick=${reset}>再建一个</button></p>
</div>
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
return html`
<div class="page">
<header class="head">
<div>
<h1>创建营销活动</h1>
<p class="sub">一份表单状态分三步填,「下一步」被当前步的校验拦住,最后一步过目再提交</p>
</div>
</header>
<div class="steps">
${STEPS.map((s, i) => html`
<div key=${i} class=${"stepdot" + (i < step ? " done" : i === step ? " now" : "")}>
<div class="bar"></div>${i + 1}. ${s.title}
</div>`)}
</div>
<div class="card panel">
${step === 0 && html`
<label>活动名称
<input value=${f.name} onChange=${set("name")} placeholder="至少 4 个字" autoFocus />
</label>
<${Err} k="name" />
<label>活动类型
<select value=${f.type} onChange=${set("type")}>
${TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
</select>
</label>`}
${step === 1 && html`
<label>开始日期
<input type="date" value=${f.start} onChange=${set("start")} />
</label>
<${Err} k="start" />
<label>预算(¥)
<input type="number" min="0" step="100" value=${f.budget} onChange=${set("budget")} />
</label>
<${Err} k="budget" />
<label>备注(选填)
<textarea rows="2" value=${f.note} onChange=${set("note")}></textarea>
</label>`}
${step === 2 && html`
<dl class="review">
<div><dt>活动名称</dt><dd>${f.name.trim()}</dd></div>
<div><dt>活动类型</dt><dd>${f.type}</dd></div>
<div><dt>开始日期</dt><dd>${f.start}</dd></div>
<div><dt>预算</dt><dd>¥ ${Number(f.budget).toLocaleString("zh-CN")}</dd></div>
<div><dt>备注</dt><dd>${f.note.trim() || "—"}</dd></div>
</dl>`}
<div class="actions">
${step > 0 && html`<button class="btn" onClick=${back}>上一步</button>`}
${step < STEPS.length - 1
? html`<button class="btn primary" onClick=${next}>下一步</button>`
: html`<button class="btn primary" onClick=${submit}>确认创建</button>`}
</div>
</div>
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>