跳转至

筛选面板(高级筛选 / 组合搜索)

工具栏塞不下的筛选就归它:一栏控件——文本搜索、下拉、复选组、数值范围——组合成一个 { search, filters } 对象驱动结果列表。筛选维度一多、条件要组合、链接要可分享,就是它。

试这几下:

  • 随便改控件 —— 结果不动,「应用」按钮亮起并预告条数;点它(或搜索框回车)才提交
  • 应用后活跃筛选变成结果区上方的可删 chip,点 × 单删即时生效;「清除全部」一键归零
  • 勾一个分类、再把价格下限抬到 500 —— 结果归零,出现空态
  • 盯着面板底部的 ListParams JSON —— 每个控件怎么落进 { search, filters } 一目了然

规矩

  • 多控件一个形状:所有控件归并进 ListParams{ search, filters }——下拉是单值、复选组是数组、范围拆成 priceMin/priceMax 两键。demo 在内存里过滤,真实版把同一份参数交给 Repository.list()(服务端过滤)
  • 筛选状态进 URL:真实版用 validateSearch 把 filters 放进 URL,链接可分享、后退可回溯;适配器按白名单映射筛选键(filterColumns),未知键直接丢弃。demo 是内存态,省掉了这一层
  • 「清除全部」和结果计数是标配:计数实时跟随,空结果给空态;活跃筛选必须可见(chip)且可单个撤销
  • 待应用态是取舍:筛选便宜(内存、快查询)就即改即生效;昂贵(慢查询、按次计费)就像 demo 这样攒草稿、点「应用」一次提交

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

demo 源码:assets/demo-filter-panel.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-filter-panel.html —— 「筛选面板」形状的最小可玩实现(蓝本:open-dashboard 的 add-filter-panel + templates/filter-panel.tsx)。
  复现的不变量:
  - 多控件(文本搜索 / 状态下拉 / 分类复选组 / 价格范围)组合成同一个 ListParams 形状:
    { search, filters } → 结果列表;面板底部回显该 JSON,契约可见
  - 待应用态:改控件先落草稿,点「应用」(或搜索框回车)才提交;应用后的活跃筛选以可删 chip 回显
  - 「清除全部」一键归零;结果计数实时,空结果有空态
  - 真实版 filters 进 URL(可分享),Repository 适配器白名单筛选键;demo 用内存过滤代替
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .layout { display: grid; grid-template-columns: 236px 1fr; gap: 12px; align-items: start; }
  @media (max-width: 620px) { .layout { grid-template-columns: 1fr; } }
  .fpanel { padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
  .field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
  .field input, .field select { width: 100%; }
  .range { display: grid; grid-template-columns: 1fr auto 1fr; gap: 6px; align-items: center; }
  .checks { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--fg); }
  .checks label { display: flex; gap: 7px; align-items: center; cursor: pointer; }
  .chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 10px; font-size: 13px; }
  .chip .x { border: 0; background: none; color: inherit; cursor: pointer; padding: 0 0 0 5px; font: inherit; }
  .params { margin: 0; padding: 9px 10px; background: var(--bg); border: 1px solid var(--border);
            border-radius: 8px; font-size: 11px; line-height: 1.5; overflow-x: auto; }
  .hint { margin: 0; font-size: 12px; color: var(--chip-warn-fg); }
</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 CATEGORIES = ["家居", "音频", "穿戴", "配件"];
const STATUSES = ["全部", "在售", "草稿", "下架"];
const STATUS_CHIP = { "在售": "chip-on", "草稿": "chip-warn", "下架": "chip-off" };
const DATA = [
  { id: 1,  name: "极光台灯",     category: "家居", status: "在售", price: 79  },
  { id: 2,  name: "云鸣头戴耳机", category: "音频", status: "在售", price: 199 },
  { id: 3,  name: "脉冲智能手表", category: "穿戴", status: "草稿", price: 249 },
  { id: 4,  name: "雪松书桌",     category: "家居", status: "下架", price: 420 },
  { id: 5,  name: "回声音箱",     category: "音频", status: "在售", price: 129 },
  { id: 6,  name: "帆影键盘",     category: "配件", status: "在售", price: 89  },
  { id: 7,  name: "轨道鼠标",     category: "配件", status: "草稿", price: 49  },
  { id: 8,  name: "潮汐耳塞",     category: "音频", status: "在售", price: 149 },
  { id: 9,  name: "青岩显示器",   category: "配件", status: "下架", price: 329 },
  { id: 10, name: "光环指环",     category: "穿戴", status: "在售", price: 179 },
  { id: 11, name: "台地置物架",   category: "家居", status: "草稿", price: 139 },
  { id: 12, name: "流明充电器",   category: "配件", status: "在售", price: 39  },
  { id: 13, name: "晨昏氛围灯",   category: "家居", status: "在售", price: 59  },
  { id: 14, name: "远山降噪耳机", category: "音频", status: "下架", price: 279 },
];

const EMPTY = { search: "", status: "全部", cats: [], min: "", max: "" };

// 各控件归并成蓝本要求的 ListParams 形状:{ search, filters }
function buildParams(s) {
  const filters = {};
  if (s.status !== "全部") filters.status = s.status;
  if (s.cats.length > 0) filters.category = s.cats;
  if (s.min !== "") filters.priceMin = Number(s.min);
  if (s.max !== "") filters.priceMax = Number(s.max);
  return { search: s.search.trim(), filters };
}

// demo 在内存里过滤;真实版把同一份 params 交给 Repository.list()(服务端过滤)
function runList({ search, filters }) {
  const kw = search.toLowerCase();
  return DATA.filter(p =>
    (!kw || p.name.toLowerCase().includes(kw)) &&
    (filters.status === undefined || p.status === filters.status) &&
    (filters.category === undefined || filters.category.includes(p.category)) &&
    (filters.priceMin === undefined || p.price >= filters.priceMin) &&
    (filters.priceMax === undefined || p.price <= filters.priceMax));
}

function Chip({ label, onRemove }) {
  return html`
    <span class="chip chip-off">${label}
      <button class="x" aria-label=${"移除筛选 " + label} onClick=${onRemove}>×</button>
    </span>`;
}

function App() {
  const [draft, setDraft] = useState(EMPTY);     // 待应用态:控件改这里,不立即生效
  const [applied, setApplied] = useState(EMPTY); // 已应用态:唯一驱动结果列表的状态
  const [toast, setToast] = useState("");

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

  const params = useMemo(() => buildParams(applied), [applied]);
  const results = useMemo(() => runList(params), [params]);
  const preview = useMemo(() => runList(buildParams(draft)).length, [draft]);
  const dirty = JSON.stringify(buildParams(draft)) !== JSON.stringify(params);
  const hasActive = params.search !== "" || Object.keys(params.filters).length > 0;
  const f = params.filters;

  const set = patch => setDraft(d => ({ ...d, ...patch }));
  const toggleCat = c => set({
    cats: draft.cats.includes(c) ? draft.cats.filter(x => x !== c) : [...draft.cats, c],
  });
  const apply = () => { setApplied(draft); setToast(`已应用 · ${preview} 条结果`); };
  // 删 chip 是即时撤销:草稿和已应用一起改
  const removeBoth = patch => {
    setDraft(d => ({ ...d, ...patch }));
    setApplied(a => ({ ...a, ...patch }));
  };
  const removeCat = c => {
    setDraft(d => ({ ...d, cats: d.cats.filter(x => x !== c) }));
    setApplied(a => ({ ...a, cats: a.cats.filter(x => x !== c) }));
  };
  const clearAll = () => { setDraft(EMPTY); setApplied(EMPTY); setToast("已清除全部筛选"); };

  const priceLabel =
    f.priceMin !== undefined && f.priceMax !== undefined ? `价格 ¥${f.priceMin}–¥${f.priceMax}`
    : f.priceMin !== undefined ? `价格 ≥ ¥${f.priceMin}`
    : `价格 ≤ ¥${f.priceMax}`;

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>商品库筛选</h1>
          <p class="sub">左侧改控件先落草稿,点「应用」才提交;活跃筛选变成结果区上方的可删 chip</p>
        </div>
      </header>

      <div class="layout">
        <aside class="card fpanel" aria-label="筛选面板">
          <label class="field">搜索
            <input placeholder="按名称搜索…" value=${draft.search}
                   onChange=${e => set({ search: e.target.value })}
                   onKeyDown=${e => { if (e.key === "Enter" && dirty) apply(); }} />
          </label>

          <label class="field">状态
            <select value=${draft.status} onChange=${e => set({ status: e.target.value })}>
              ${STATUSES.map(s => html`<option key=${s}>${s}</option>`)}
            </select>
          </label>

          <div class="field">分类
            <div class="checks">
              ${CATEGORIES.map(c => html`
                <label key=${c}>
                  <input type="checkbox" checked=${draft.cats.includes(c)}
                         onChange=${() => toggleCat(c)} />${c}
                </label>`)}
            </div>
          </div>

          <div class="field">价格范围(¥)
            <div class="range">
              <input type="number" min="0" placeholder="最低" aria-label="最低价" value=${draft.min}
                     onChange=${e => set({ min: e.target.value })} />
              <span>–</span>
              <input type="number" min="0" placeholder="最高" aria-label="最高价" value=${draft.max}
                     onChange=${e => set({ max: e.target.value })} />
            </div>
          </div>

          <button class="btn primary" disabled=${!dirty} onClick=${apply}>
            ${dirty ? `应用筛选(${preview} 条)` : "应用筛选"}
          </button>
          ${dirty && html`<p class="hint">有未应用的改动</p>`}

          <div class="field">当前 ListParams(已应用)
            <pre class="params">${JSON.stringify(params, null, 2)}</pre>
          </div>
        </aside>

        <section>
          <div class="chips">
            <span>共 <strong>${results.length}</strong> 条结果</span>
            ${params.search !== "" && html`
              <${Chip} label=${`搜索「${params.search}」`} onRemove=${() => removeBoth({ search: "" })} />`}
            ${f.status !== undefined && html`
              <${Chip} label=${`状态:${f.status}`} onRemove=${() => removeBoth({ status: "全部" })} />`}
            ${(f.category || []).map(c => html`
              <${Chip} key=${c} label=${`分类:${c}`} onRemove=${() => removeCat(c)} />`)}
            ${(f.priceMin !== undefined || f.priceMax !== undefined) && html`
              <${Chip} label=${priceLabel} onRemove=${() => removeBoth({ min: "", max: "" })} />`}
            ${hasActive && html`<button class="link" onClick=${clearAll}>清除全部</button>`}
          </div>

          <table class="list">
            <thead>
              <tr><th>名称</th><th>分类</th><th>状态</th><th class="num">价格</th></tr>
            </thead>
            <tbody>
              ${results.map(p => html`
                <tr key=${p.id}>
                  <td style=${{ fontWeight: 550 }}>${p.name}</td>
                  <td>${p.category}</td>
                  <td><span class=${"chip " + STATUS_CHIP[p.status]}>${p.status}</span></td>
                  <td class="num">¥${p.price}</td>
                </tr>`)}
              ${results.length === 0 && html`
                <tr><td class="empty" colSpan="4">没有匹配的商品 —— 试试放宽价格范围或清除部分筛选</td></tr>`}
            </tbody>
          </table>
        </section>
      </div>

      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>