跳转至

关联记录(父记录 + 内联子列表)

一屏一父:详情页里同时把「父记录概览」和它的一对多子表摊开来看。相比 master-detail(列表+侧栏)和 record-tabs(子表塞进 tab),关联记录是把子表直接嵌在父页上——同页可以并排两三份。

试这几下:

  • 顶部一张客户信息卡(父记录),下面一张关联订单表(子记录,5 行起)
  • 标题右侧的计数徽标 = 表里行数,添加/移除后立即刷新
  • 添加关联 —— 弹层里从候选池(未关联的订单)搜索并勾选,加入后 toast
  • 每行右侧移除 —— 破坏性操作,先弹确认再执行;确认后 toast
  • 全部关联完,候选池清空,「添加关联」按钮自动禁用

规矩

  • 子表只做展示:组件对行类型泛型,把「已按父 id 过滤好的行」传进去,它自己不发请求。demo 里 rows 由 orderIds 派生;真实版是子资源自己的 list query({ filters: { <parentId>: id } }) 拉出来传入
  • 关系落在关联表,不是自定义 join:添加/移除是一次 mutation,成功后失效父资源 + 该子资源 list 键,两边同步刷新
  • 计数徽标 = rows.length,永远跟着 rows 走,别单独维护一个计数状态
  • 移除先确认,添加/移除后都出 toast;空表有干净的空态;候选池空了 添加关联 禁用
  • 一页可以挂多份 RelatedList(比如客户下同时挂订单和发票),每份独立列定义、独立空态、独立 View all 链接

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

demo 源码:assets/demo-related-records.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-related-records.html —— 「关联记录」形状的最小可玩实现(蓝本:open-dashboard 的 RelatedList + related-records 模板)。
  复现的不变量:
  - RelatedList 是展示型、对行类型泛型:把「已按父 id 过滤好的行」传进来,组件自己不发请求
  - 子记录来自「子资源的 list query,按父 id 过滤」,不是自定义 join
  - 一屏一父,多父就多页;子列表标题旁的计数徽标 = rows.length,随 rows 变化
  - 破坏性关联操作(移除)先过确认;添加/移除都出 toast
  demo 层的简化:关系存在父对象的 orderIds 里(内存),真实版走关联表 mutation 后失效父 + 子 list 键。
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .customer { padding: 14px 16px; margin-bottom: 12px; }
  .customer h2 { margin: 0 0 6px; font-size: 17px; }
  .customer dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
                 gap: 6px 14px; margin: 6px 0 0; }
  .customer dt { margin: 0; font-size: 12px; color: var(--muted); }
  .customer dd { margin: 0 0 4px; font-size: 13px; font-weight: 550; }
  .rel-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 8px; }
  .rel-head h3 { margin: 0; font-size: 15px; }
  .rel-head .badge { display: inline-flex; align-items: center; min-width: 22px; padding: 1px 8px;
                     border-radius: 999px; background: var(--chip-off-bg); color: var(--chip-off-fg);
                     font-size: 12px; font-weight: 600; justify-content: center; }
  .rel-head .spacer { flex: 1; }
  .cand-list { max-height: 300px; overflow-y: auto; border: 1px solid var(--border);
               border-radius: 8px; }
  .cand-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
              border-bottom: 1px solid var(--border); font-size: 13px; }
  .cand-row:last-child { border-bottom: 0; }
  .cand-row .meta { flex: 1; display: flex; flex-direction: column; gap: 2px; }
  .cand-row .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
  .cand-row .num { color: var(--muted); font-size: 12px; }
</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 CUSTOMER = {
  id: "cus_001",
  name: "陈禾(示例商贸有限公司)",
  contact: "陈禾",
  email: "chenhe@corp.cn",
  plan: "企业版",
  since: "2024-02-11",
};

// 全体订单池(内存态):已关联到该客户的用 orderIds 引用,未关联的作为候选出现
const ALL_ORDERS = [
  { id: "ORD-2048", date: "2026-05-14", items: 3, total: 184.00, status: "已发货" },
  { id: "ORD-2031", date: "2026-05-02", items: 1, total: 49.00,  status: "处理中" },
  { id: "ORD-1997", date: "2026-04-21", items: 5, total: 312.50, status: "已发货" },
  { id: "ORD-1960", date: "2026-04-08", items: 2, total: 98.00,  status: "已退款" },
  { id: "ORD-1922", date: "2026-03-19", items: 4, total: 226.00, status: "已发货" },
  { id: "ORD-1880", date: "2026-03-02", items: 1, total: 39.00,  status: "处理中" },
  { id: "ORD-1841", date: "2026-02-18", items: 6, total: 402.00, status: "已发货" },
  { id: "ORD-1802", date: "2026-01-30", items: 2, total: 128.00, status: "已发货" },
  { id: "ORD-1765", date: "2026-01-11", items: 3, total: 176.00, status: "已退款" },
  { id: "ORD-1721", date: "2025-12-22", items: 1, total: 52.00,  status: "已发货" },
];

const STATUS_CHIP = { "已发货": "chip-on", "处理中": "chip-warn", "已退款": "chip-off" };

const fmtMoney = n => "¥" + n.toFixed(2);

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 AddDialog({ candidates, onAdd, onCancel }) {
  const [q, setQ] = useState("");
  const [picked, setPicked] = useState({});
  const kw = q.trim().toLowerCase();
  const list = candidates.filter(o =>
    !kw || o.id.toLowerCase().includes(kw) || o.status.includes(kw));
  const pickedIds = Object.keys(picked).filter(k => picked[k]);

  return html`
    <div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onCancel(); }}>
      <div class="modal" style=${{ maxWidth: 460 }}>
        <h2>添加关联订单</h2>
        <p style=${{ margin: 0, color: "var(--muted)", fontSize: 12 }}>
          从未关联的订单中勾选,加入到该客户名下。
        </p>
        <input placeholder="搜索订单号或状态…" value=${q} onChange=${e => setQ(e.target.value)} />
        <div class="cand-list">
          ${list.length === 0 && html`<div class="empty">没有匹配的候选订单</div>`}
          ${list.map(o => html`
            <label class="cand-row" key=${o.id}>
              <input type="checkbox" checked=${!!picked[o.id]}
                     onChange=${() => setPicked({ ...picked, [o.id]: !picked[o.id] })} />
              <div class="meta">
                <span class="mono">${o.id} · ${o.date}</span>
                <span class="num">${o.items} 件 · ${fmtMoney(o.total)} · ${o.status}</span>
              </div>
            </label>`)}
        </div>
        <div class="actions">
          <button class="btn" onClick=${onCancel}>取消</button>
          <button class="btn primary" disabled=${pickedIds.length === 0}
                  onClick=${() => onAdd(pickedIds)}>
            加入 ${pickedIds.length > 0 ? `(${pickedIds.length})` : ""}
          </button>
        </div>
      </div>
    </div>`;
}

function App() {
  // 父记录的关联子 id 列表(真实版走关联表;这里就是父对象的一个数组)
  const [orderIds, setOrderIds] = useState(["ORD-2048", "ORD-2031", "ORD-1997", "ORD-1960", "ORD-1922"]);
  const [dialog, setDialog] = useState(null);   // "add" | null
  const [confirm, setConfirm] = useState(null); // { text, onOk }
  const [toast, setToast] = useState("");

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

  // 展示型的 rows:由 orderIds 拉出对应的订单(真实版:子资源 list query({ filters: { customerId } }))
  const rows = useMemo(
    () => orderIds.map(id => ALL_ORDERS.find(o => o.id === id)).filter(Boolean),
    [orderIds]);
  const candidates = useMemo(
    () => ALL_ORDERS.filter(o => !orderIds.includes(o.id)),
    [orderIds]);

  const addMany = ids => {
    setOrderIds(prev => [...ids, ...prev]);
    setDialog(null);
    setToast(`已关联 ${ids.length} 张订单`);
  };
  const removeOne = id => {
    setConfirm({
      text: `移除订单 ${id} 与该客户的关联?关联可稍后再添加回来。`,
      onOk: () => {
        setOrderIds(prev => prev.filter(x => x !== id));
        setConfirm(null);
        setToast(`已移除 ${id}`);
      },
    });
  };

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>客户详情</h1>
          <p class="sub">父记录信息 + 下方关联订单,计数徽标随 rows 同步</p>
        </div>
      </header>

      <section class="card customer">
        <h2>${CUSTOMER.name} <span class="chip chip-on" style=${{ marginLeft: 6 }}>活跃</span></h2>
        <dl>
          <div><dt>主要联系人</dt><dd>${CUSTOMER.contact}</dd></div>
          <div><dt>邮箱</dt><dd>${CUSTOMER.email}</dd></div>
          <div><dt>套餐</dt><dd>${CUSTOMER.plan}</dd></div>
          <div><dt>客户 id</dt><dd style=${{ fontFamily: "ui-monospace, monospace", fontSize: 12 }}>${CUSTOMER.id}</dd></div>
          <div><dt>建档时间</dt><dd>${CUSTOMER.since}</dd></div>
        </dl>
      </section>

      <div class="rel-head">
        <h3>关联订单</h3>
        <span class="badge" aria-label=${`共 ${rows.length} 条`}>${rows.length}</span>
        <span class="spacer"></span>
        <button class="btn sm primary"
                disabled=${candidates.length === 0}
                onClick=${() => setDialog("add")}>
          添加关联
        </button>
      </div>

      <table class="list">
        <thead>
          <tr>
            <th>订单号</th>
            <th>下单日期</th>
            <th class="num">件数</th>
            <th class="num">金额</th>
            <th>状态</th>
            <th class="ops">操作</th>
          </tr>
        </thead>
        <tbody>
          ${rows.map(o => html`
            <tr key=${o.id}>
              <td style=${{ fontFamily: "ui-monospace, monospace" }}>${o.id}</td>
              <td>${o.date}</td>
              <td class="num">${o.items}</td>
              <td class="num">${fmtMoney(o.total)}</td>
              <td><span class=${"chip " + STATUS_CHIP[o.status]}>${o.status}</span></td>
              <td class="ops"><button class="link danger" onClick=${() => removeOne(o.id)}>移除</button></td>
            </tr>`)}
          ${rows.length === 0 && html`
            <tr><td class="empty" colSpan="6">该客户暂无关联订单,点右上「添加关联」挂上一张。</td></tr>`}
        </tbody>
      </table>

      <p class="sub" style=${{ marginTop: 10 }}>
        候选池剩余 ${candidates.length} 张未关联订单${candidates.length === 0 ? "(已全部关联)" : ""}
      </p>

      ${dialog === "add" && html`
        <${AddDialog} candidates=${candidates} onAdd=${addMany} onCancel=${() => setDialog(null)} />`}
      ${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>