跳转至

主从视图(Master-detail split)

一屏两栈:左边是资源列表始终挂载,右边是选中那条的详情。做「一屏看多条 + 一次编一条」的场景,比进详情页再退回列表少一次上下文切换,也不打断列表的搜索/排序状态。

试这几下:

  • 点左侧任意订单 —— 右侧详情联动切换,选中行高亮,列表位置不重置
  • 详情里按编辑改字段 → 保存,左侧对应行同步刷新,toast 提示
  • 顶部搜索框过滤单号/名称/客户;改搜索关键字不影响右侧当前选中
  • 删除先弹确认;删完选中自动移到下一条,主列表原地更新
  • 把窗口拖窄到 720px 以下:只显示列表;点一条进详情,详情内有「← 返回列表」

规矩

  • 选中锚点唯一:真实版把选中放进 URL(嵌套路由 /<resource>/$id),列表始终挂载、搜索/排序参数原样保留;demo 简化成 useState(selId),刷新即丢,但外壳交互一致
  • 主表和详情各自成栈:真实版 list query 一直缓存着,详情面板独立跑 <name>DetailQuery(id),两边加载互不阻塞;demo 共用一份内存数组,选行只是切 id
  • id 失效走面板本地的 not-found:真实版 detail 的 loader throw notFound(),触发路由自己的 notFoundComponent,只替换面板不掀翻整个分栏;demo 里删除会把选中挪到下一条,选空时右侧渲染「订单不存在」占位
  • 编辑 = 保存 + 失效查询键:真实版调 useUpdate<Name> mutation,成功后 react-query invalidate 该资源的 list 与 detail 两处,两侧自动重取;demo 直接 setRows(...) 写同一份内存源,效果一致但没有加载/错误态
  • 关闭详情保留列表参数:真实版关闭按钮 navigate({ to: "/<name>", search: (prev) => prev });demo 只在窄屏有「返回列表」,宽屏两栏并列不需要关闭
  • 破坏性操作先过确认弹窗;任何变更成功后 toast

蓝本:add-master-detail.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-master-detail.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-master-detail.html —— 「主从视图(Master-detail split)」形状的最小可玩实现(蓝本:open-dashboard 的 add-master-detail:orders.tsx + orders.$id.tsx,选中在 URL、列表始终挂载)。
  复现的不变量:
  - 选中锚点唯一:列表始终挂载,选中一条即打开详情侧栏;真实版把 id 放进 URL(/orders/$id),demo 用 useState(selId) 简化
  - 主表和详情各自成栈:列表参数(搜索/排序/分页)不因打开详情而丢失;关闭详情回到列表时不重置这些参数
  - 详情自带 not-found:id 失效/被删时,只有详情面板换成缺失态,主列表不掀翻
  - 编辑走「保存 → 失效查询键」:真实版调 update mutation 成功后 invalidate list + detail 两处查询键;demo 用 setRows 直接同步一份内存源
  - 破坏性操作先确认;变更成功后 toast
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .md-split { display: grid; grid-template-columns: minmax(220px, 34%) 1fr; gap: 12px; align-items: start; }
  .md-list { background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
  .md-search { padding: 8px; border-bottom: 1px solid var(--border); }
  .md-search input { width: 100%; }
  .md-rows { list-style: none; margin: 0; padding: 0; max-height: 520px; overflow-y: auto; }
  .md-row { padding: 10px 12px; border-bottom: 1px solid var(--border); cursor: pointer; }
  .md-row:last-child { border-bottom: 0; }
  .md-row:hover { background: rgba(53,100,215,0.06); }
  .md-row.on { background: rgba(53,100,215,0.12); box-shadow: inset 3px 0 0 var(--primary); }
  .md-row-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
  .md-code { font: 500 12px/1.4 ui-monospace, "SF Mono", Consolas, monospace; color: var(--muted); }
  .md-row-mid { margin: 2px 0; font-weight: 550; }
  .md-row-sub { color: var(--muted); font-size: 12px; }
  .md-rows .empty { padding: 26px 12px; text-align: center; color: var(--muted); list-style: none; }
  .md-detail { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
  .md-back { margin-bottom: 8px; }
  .md-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 12px; }
  .md-head h2 { margin: 0; font-size: 17px; }
  .md-fields { display: grid; grid-template-columns: 72px 1fr; column-gap: 12px; row-gap: 8px; margin: 0 0 14px; font-size: 13px; }
  .md-fields dt { color: var(--muted); }
  .md-fields dd { margin: 0; }
  .md-form { display: flex; flex-direction: column; gap: 10px; }
  .md-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
  .md-form input, .md-form select, .md-form textarea { width: 100%; }
  .md-missing { padding: 8px 0; }
  .md-missing h2 { margin: 0 0 4px; font-size: 15px; }
  .md-missing p { margin: 0; color: var(--muted); font-size: 13px; }
  @media (max-width: 719px) {
    .md-split { grid-template-columns: 1fr; }
    .md-rows { max-height: none; }
  }
</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 SEED = [
  { id: "SO-2401", name: "六月鲜果补货",   customer: "青禾杂货铺",   status: "已完成", total: 12480, createdAt: "2026-06-02", description: "端午前一周补货,含冷链配送 3 车。" },
  { id: "SO-2402", name: "促销物料铺货",   customer: "南门便利店",   status: "进行中", total: 8620,  createdAt: "2026-06-05", description: "端午海报 + 试吃赠品,本周内送达。" },
  { id: "SO-2403", name: "空调滤芯 40 组", customer: "永安电器",     status: "待处理", total: 3200,  createdAt: "2026-06-11", description: "客户已确认款式,待仓库备货。" },
  { id: "SO-2404", name: "定制礼盒 200 份", customer: "锦江酒店集团", status: "已完成", total: 24600, createdAt: "2026-05-28", description: "中秋礼盒预订,含 logo 烫印。" },
  { id: "SO-2405", name: "夜宵档补酒",     customer: "老巷夜宵",     status: "已取消", total: 1780,  createdAt: "2026-06-01", description: "客户临时改成月结账,改走新单。" },
  { id: "SO-2406", name: "校服返厂修补",   customer: "白鹭小学",     status: "进行中", total: 4560,  createdAt: "2026-06-08", description: "60 件春装校服返厂修补袖口。" },
  { id: "SO-2407", name: "咖啡豆 30 kg",   customer: "半山咖啡",     status: "已完成", total: 5940,  createdAt: "2026-05-19", description: "危地马拉与耶加雪菲各 15 kg。" },
  { id: "SO-2408", name: "会议用花艺",     customer: "云海会展",     status: "待处理", total: 2100,  createdAt: "2026-06-13", description: "6/20 会议主桌花艺,色系待客户回复。" },
  { id: "SO-2409", name: "自营店招牌换新", customer: "南门便利店",   status: "进行中", total: 7300,  createdAt: "2026-06-04", description: "两块亚克力招牌 + 灯带更换。" },
  { id: "SO-2410", name: "生鲜周订单",     customer: "青禾杂货铺",   status: "进行中", total: 9860,  createdAt: "2026-06-10", description: "本周日常生鲜配送,周二/五各一趟。" },
  { id: "SO-2411", name: "端午粽子团购",   customer: "锦江酒店集团", status: "已完成", total: 15680, createdAt: "2026-05-22", description: "员工团购粽子 400 盒,已交付食堂。" },
  { id: "SO-2412", name: "空气炸锅样品",   customer: "永安电器",     status: "待处理", total: 990,   createdAt: "2026-06-14", description: "3 台样品先寄门店测试。" },
];

function statusClass(s) {
  if (s === "已完成") return "chip-on";
  if (s === "进行中") return "chip-warn";
  return "chip-off"; // 待处理 / 已取消
}

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 [selId, setSelId] = useState(SEED[0].id); // 真实版:URL 里的 $id;demo:局部 state
  const [q, setQ] = useState("");
  const [form, setForm] = useState(null);         // 编辑草稿:null=只读;否则为一份 row 的浅拷贝
  const [confirm, setConfirm] = useState(null);
  const [toast, setToast] = useState("");
  const [narrow, setNarrow] = useState(() => typeof window !== "undefined" && window.innerWidth < 720);

  useEffect(() => {
    const on = () => setNarrow(window.innerWidth < 720);
    window.addEventListener("resize", on);
    return () => window.removeEventListener("resize", on);
  }, []);

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(""), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  const filtered = useMemo(() => {
    const kw = q.trim().toLowerCase();
    if (!kw) return rows;
    return rows.filter(r =>
      r.id.toLowerCase().includes(kw) ||
      r.name.toLowerCase().includes(kw) ||
      r.customer.toLowerCase().includes(kw));
  }, [rows, q]);

  const selected = rows.find(r => r.id === selId) || null;
  const editing = form !== null && selected !== null && form.id === selected.id;

  const pickRow = (id) => { setSelId(id); setForm(null); };
  const startEdit = () => setForm({ ...selected });
  const cancelEdit = () => setForm(null);
  const saveEdit = () => {
    const cleaned = { ...form, total: Number(form.total) || 0 };
    // 真实版:update mutation 成功 → 失效 list + detail 查询键,两侧自动刷新
    setRows(rs => rs.map(r => (r.id === cleaned.id ? cleaned : r)));
    setForm(null);
    setToast(`已保存 ${cleaned.id}`);
  };
  const askDelete = () => {
    if (!selected) return;
    setConfirm({
      text: `删除订单「${selected.name}」(${selected.id})?此操作不可撤销。`,
      onOk: () => {
        const idx = rows.findIndex(r => r.id === selected.id);
        const next = rows.filter(r => r.id !== selected.id);
        setRows(next);
        // 挪到下一条(真实版:路由自己走 notFoundComponent,面板换成缺失态)
        setSelId(next[Math.min(idx, next.length - 1)]?.id ?? null);
        setForm(null);
        setConfirm(null);
        setToast(`已删除 ${selected.id}`);
      },
    });
  };

  // 窄屏路由:未选中只显示列表;选中只显示详情(内含「返回列表」)
  const showList = !narrow || !selId;
  const showDetail = !narrow || !!selId;

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>订单</h1>
          <p class="sub">左列表 + 右详情 · 选行联动详情 · 详情可编辑保存回列表</p>
        </div>
      </header>

      <div class="md-split">
        ${showList && html`
          <div class="md-list">
            <div class="md-search">
              <input placeholder="搜索单号 / 名称 / 客户…" value=${q}
                     onChange=${e => setQ(e.target.value)} />
            </div>
            <ul class="md-rows">
              ${filtered.map(r => html`
                <li key=${r.id}
                    class=${"md-row " + (r.id === selId ? "on" : "")}
                    onClick=${() => pickRow(r.id)}>
                  <div class="md-row-top">
                    <span class="md-code">${r.id}</span>
                    <span class=${"chip " + statusClass(r.status)}>${r.status}</span>
                  </div>
                  <div class="md-row-mid">${r.name}</div>
                  <div class="md-row-sub">${r.customer} · ¥${r.total.toLocaleString("zh-CN")}</div>
                </li>`)}
              ${filtered.length === 0 && html`<li class="empty">没有匹配的订单</li>`}
            </ul>
          </div>`}

        ${showDetail && html`
          <div class="md-detail">
            ${narrow && html`
              <div class="md-back">
                <button class="link" onClick=${() => setSelId(null)}>← 返回列表</button>
              </div>`}

            ${!selected && html`
              <div class="md-missing">
                <h2>订单不存在</h2>
                <p>它可能已被删除或从未存在,从左侧列表挑一条继续。</p>
              </div>`}

            ${selected && !editing && html`
              <div>
                <div class="md-head">
                  <div>
                    <h2>${selected.name}</h2>
                    <p class="sub">${selected.id} · 创建于 ${selected.createdAt}</p>
                  </div>
                  <span class=${"chip " + statusClass(selected.status)}>${selected.status}</span>
                </div>
                <dl class="md-fields">
                  <dt>客户</dt><dd>${selected.customer}</dd>
                  <dt>金额</dt><dd class="num">¥${selected.total.toLocaleString("zh-CN")}</dd>
                  <dt>状态</dt><dd>${selected.status}</dd>
                  <dt>创建</dt><dd>${selected.createdAt}</dd>
                  <dt>备注</dt><dd>${selected.description || "—"}</dd>
                </dl>
                <div class="actions">
                  <button class="btn" onClick=${startEdit}>编辑</button>
                  <button class="btn" style=${{ color: "var(--danger)" }} onClick=${askDelete}>删除</button>
                </div>
              </div>`}

            ${selected && editing && html`
              <div class="md-form">
                <div class="md-head">
                  <div>
                    <h2>编辑 ${selected.id}</h2>
                    <p class="sub">改完点保存,左侧列表会同步刷新</p>
                  </div>
                </div>
                <label>名称
                  <input value=${form.name}
                         onChange=${e => setForm({ ...form, name: e.target.value })} />
                </label>
                <label>客户
                  <input value=${form.customer}
                         onChange=${e => setForm({ ...form, customer: e.target.value })} />
                </label>
                <div class="row2">
                  <label>状态
                    <select value=${form.status}
                            onChange=${e => setForm({ ...form, status: e.target.value })}>
                      <option>待处理</option>
                      <option>进行中</option>
                      <option>已完成</option>
                      <option>已取消</option>
                    </select>
                  </label>
                  <label>金额(¥)
                    <input type="number" min="0" value=${form.total}
                           onChange=${e => setForm({ ...form, total: e.target.value })} />
                  </label>
                </div>
                <label>备注
                  <textarea rows="3" value=${form.description}
                            onChange=${e => setForm({ ...form, description: e.target.value })}></textarea>
                </label>
                <div class="actions">
                  <button class="btn" onClick=${cancelEdit}>取消</button>
                  <button class="btn primary" onClick=${saveEdit}>保存</button>
                </div>
              </div>`}
          </div>`}
      </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>