跳转至

向导表单

一份长表单拆成几步填:进度指示 + 分步校验拦住「下一步」+ 最后一步 review 过目再提交。字段多到一屏压迫、或有天然先后(开通流程、建活动、入驻申请)时用它。

试这几下:

  • 什么都不填直接点下一步 —— 被当前步校验拦住,错误就地显示
  • 填好走到第 2 步再上一步 —— 已填的值一个不丢
  • 最后一步是 review:所有值列出来过目,确认才真正提交(成功面板 + toast)

规矩

  • 一份表单状态贯穿整个向导(蓝本是一个 TanStack Form 的 useForm),步骤只是它的视图切片 —— 绝不一步一个表单再手工拼合
  • 「下一步」被当前步的校验拦住:只校验本步字段(蓝本按 zod schema.shape[name] 逐字段 safeParse),错误就地显示
  • 最后一步 review 展示全部值;真实版提交走该资源的 Repository mutation,服务端校验失败把错误回填到表单
  • 提交结果必反馈:成功 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>