全局搜索(⌘K 命令面板 + 跨资源检索)
一个盒子搜整站的所有东西。按 ⌘K 弹面板,键入几个字就命中跨资源的分组结果 —— 产品、订单、客户、快捷操作各是一组;↑↓ 移动、Enter 跳转、Esc 关闭,鼠标可以完全不动。资源多起来后,这是让"我要跳去某个具体东西"从翻侧栏变成一秒的形状。
试这几下:
- 按 ⌘K / Ctrl-K(或右上角按钮)唤起面板 —— 输入框自动获焦
- 空关键字看到 最近访问 组(首次为空,选几条后 localStorage 保存 · 刷新还在)
- 键入
陈/SO-/保温—— 命中项按 产品 / 订单 / 客户 / 操作 四组分列,每组最多 5 条 - ↑↓ 移动高亮(跨组连续)、Enter 触发(资源类跳转 + 写入最近,操作类直接执行)、Esc 或点面板外关闭
- 「操作」组里的 清空最近访问 是红字的破坏性操作 —— 先弹确认弹窗
规矩
- 一个 SearchSource 一个契约:
{ label, items, toResult }让面板在内存里按title/subtitle过滤并按label分组;也可以给search(query)走自定义匹配。demo 是includes子串匹配,真实版把父层 debounce 后的跨资源服务端结果喂回同一契约 - 组件自己过滤(
shouldFilter={false}):为让 subtitle 也参与匹配、分组头显示精确条数;每组按limitPerGroup(demo 5、真实版默认 6)截断,防某个资源刷屏挤走其他分组 - 键盘只在面板里活着:焦点始终在输入框,↑↓ 只移动
aria-activedescendant指向的高亮项,Enter 才navigate(href)/ 触发操作;破坏性操作先过确认弹窗 - 纯展示 + 导航:面板不知道数据从哪来 —— sources 自己上游拉,命中项自带
href,选中即跳转并关闭。空关键字回显 最近访问:demo/真实版都存 localStorage,命中即推到最前、去重、Cap 长度
蓝本:
add-global-search.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-global-search.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-global-search.html —— 「全局搜索」形状的最小可玩实现(蓝本:open-dashboard 的 GlobalSearch + global-search 画廊)。
复现的不变量:
- ⌘K / Ctrl-K 唤起居中命令面板(输入框 + 分组结果 + 底部快捷键栏)
- 一个 SearchSource 一个契约:{ label, items, toResult } → 面板在内存里过滤 title/subtitle,按 label 分组;给 search(query) 是自定义匹配
- 组件自己过滤(shouldFilter=false):让 subtitle 也参与匹配、分组头显示精确条数;每组按 limitPerGroup 截断
- 键盘可达:↑↓ 移动高亮、Enter 触发所选项、Esc 关闭;焦点始终在输入框,aria-activedescendant 指向高亮项
- 空关键字显示「最近访问」(存 localStorage,命中即推到最前、去重、Cap 长度)
- 破坏性操作先过确认弹窗;变更完成 toast;点面板外也关闭
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.kbd { display: inline-flex; align-items: center; padding: 1px 6px; font-size: 12px;
font-family: ui-monospace, SFMono-Regular, monospace; color: var(--fg);
background: var(--card); border: 1px solid var(--border); border-radius: 5px;
min-width: 20px; justify-content: center; }
.head .kbd { margin: 0 1px; }
.launcher { display: flex; flex-direction: column; align-items: flex-start; gap: 10px;
padding: 20px 20px 22px; background: var(--card); border: 1px solid var(--border);
border-radius: 12px; color: var(--muted); font-size: 13px; }
.launcher h2 { color: var(--fg); margin: 0; font-size: 15px; font-weight: 600; }
.launcher .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* 命令面板 */
.pal-wrap { position: fixed; inset: 0; z-index: 40; display: flex; align-items: flex-start; justify-content: center;
padding: 12vh 16px 16px; background: rgba(0,0,0,.42); }
.pal { width: 100%; max-width: 540px; background: var(--card); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 20px 40px rgba(0,0,0,.35); overflow: hidden;
display: flex; flex-direction: column; max-height: min(72vh, 480px); }
.pal-search { display: flex; align-items: center; gap: 8px; padding: 10px 12px;
border-bottom: 1px solid var(--border); color: var(--muted); }
.pal-search input { flex: 1; border: 0; padding: 3px 0; background: none; border-radius: 0;
font-size: 14px; color: var(--fg); }
.pal-search input:focus { outline: none; }
.pal-list { flex: 1; overflow-y: auto; padding: 4px; }
.pal-group-head { padding: 8px 10px 4px; font-size: 11px; color: var(--muted);
letter-spacing: .06em; text-transform: uppercase; }
.pal-item { display: flex; width: 100%; align-items: flex-start; justify-content: space-between; gap: 10px;
border: 0; background: none; color: var(--fg); font: inherit; text-align: left;
padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.pal-item.on { background: var(--chip-off-bg); }
.pal-item .title { font-weight: 550; }
.pal-item .title.danger { color: var(--danger); }
.pal-item .sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
.pal-item .tag { font-size: 11px; color: var(--muted); white-space: nowrap; padding-top: 2px; }
.pal-empty { padding: 32px 12px; text-align: center; color: var(--muted); font-size: 13px; }
.pal-foot { display: flex; gap: 10px; align-items: center; padding: 8px 12px;
border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); flex-wrap: wrap; }
.pal-foot .hint { display: inline-flex; align-items: center; gap: 4px; }
.pal-foot .spacer { flex: 1; }
</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, useRef } = React;
const html = htm.bind(React.createElement);
const LIMIT_PER_GROUP = 5;
const RECENT_MAX = 5;
const RECENT_KEY = "demo-global-search:recent";
const PRODUCTS = [
{ id: "p01", name: "陶瓷保温杯 600ml", sku: "PC-6001", price: 89 },
{ id: "p02", name: "亚麻拼色手提袋", sku: "PC-3210", price: 45 },
{ id: "p03", name: "竹纤维毛巾套装", sku: "PC-2088", price: 128 },
{ id: "p04", name: "香薰蜡烛 · 森林", sku: "PC-4412", price: 79 },
{ id: "p05", name: "亚麻收纳篮 大号", sku: "PC-5501", price: 168 },
{ id: "p06", name: "手工陶瓷餐盘 6 件", sku: "PC-5502", price: 108 },
{ id: "p07", name: "旅行洗漱包", sku: "PC-3311", price: 66 },
];
const ORDERS = [
{ id: "o01", ref: "SO-20260701-001", customer: "陈禾", total: 289 },
{ id: "o02", ref: "SO-20260701-002", customer: "李慕白", total: 458 },
{ id: "o03", ref: "SO-20260702-001", customer: "王小满", total: 1298 },
{ id: "o04", ref: "SO-20260702-002", customer: "赵青", total: 89 },
{ id: "o05", ref: "SO-20260703-001", customer: "周芷", total: 640 },
{ id: "o06", ref: "SO-20260704-001", customer: "吴限", total: 210 },
];
const CUSTOMERS = [
{ id: "c01", name: "陈禾", email: "chenhe@corp.cn", plan: "专业版" },
{ id: "c02", name: "李慕白", email: "mubai@corp.cn", plan: "免费版" },
{ id: "c03", name: "王小满", email: "xiaoman@corp.cn", plan: "团队版" },
{ id: "c04", name: "赵青", email: "zhaoqing@corp.cn", plan: "专业版" },
{ id: "c05", name: "周芷", email: "zhouzhi@corp.cn", plan: "免费版" },
];
const ACTIONS = [
{ id: "a01", title: "新建订单", subtitle: "开一张空白订单" },
{ id: "a02", title: "导出本月销售", subtitle: "生成 CSV 并下载" },
{ id: "a03", title: "刷新所有列表", subtitle: "失效缓存重新拉取" },
{ id: "a04", title: "打开设置页", subtitle: "跳转到 /settings" },
{ id: "a05", title: "清空最近访问", subtitle: "破坏性操作,不可撤销", destructive: true },
];
// 一份 SearchSource:给 items + toResult 就走内存过滤,group 头显示 label 和精确条数。
// 真实版可以把某一组换成 search: q => rows —— 父层 debounce 后调服务端跨资源接口,结果喂回同一契约。
const SOURCES = [
{ label: "产品", items: PRODUCTS, toResult: p => ({ id: p.id, title: p.name, subtitle: "SKU " + p.sku + " · ¥" + p.price, tag: "产品" }) },
{ label: "订单", items: ORDERS, toResult: o => ({ id: o.id, title: o.ref, subtitle: o.customer + " · ¥" + o.total.toLocaleString(), tag: "订单" }) },
{ label: "客户", items: CUSTOMERS, toResult: c => ({ id: c.id, title: c.name, subtitle: c.email + " · " + c.plan, tag: "客户" }) },
{ label: "操作", items: ACTIONS, toResult: a => ({ id: a.id, title: a.title, subtitle: a.subtitle, tag: "操作", destructive: !!a.destructive, action: true }) },
];
function defaultMatch(result, query) {
const kw = query.trim().toLowerCase();
if (!kw) return true;
const hay = (result.title + " " + (result.subtitle || "")).toLowerCase();
return hay.includes(kw);
}
function groupResults(sources, query, limit) {
const groups = [];
for (const s of sources) {
const mapped = (s.items || []).map(s.toResult);
const filtered = query ? mapped.filter(r => defaultMatch(r, query)) : mapped;
if (filtered.length > 0) groups.push({ label: s.label, results: filtered.slice(0, limit) });
}
return groups;
}
function loadRecent() {
try {
const raw = localStorage.getItem(RECENT_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr.slice(0, RECENT_MAX) : [];
} catch (_) { return []; }
}
function saveRecent(list) {
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX))); } catch (_) {}
}
function Palette({ query, setQuery, groups, activeIdx, setActiveIdx, onSelect, onClose }) {
const inputRef = useRef(null);
const listRef = useRef(null);
// 展平:全局索引 = 用于键盘 activeIdx 与 aria-activedescendant 的稳定编号
const flat = [];
for (const g of groups) for (const r of g.results) flat.push({ group: g.label, result: r });
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
// 过滤收窄后高亮越界 → 回到顶部
useEffect(() => {
if (activeIdx > flat.length - 1) setActiveIdx(0);
}, [flat.length, activeIdx, setActiveIdx]);
// 高亮移动时滚到可见处
useEffect(() => {
if (!listRef.current) return;
const el = listRef.current.querySelector('[data-active="true"]');
if (el) el.scrollIntoView({ block: "nearest" });
}, [activeIdx, groups]);
const onKeyDown = e => {
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, flat.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)); }
else if (e.key === "Enter") { e.preventDefault(); const cur = flat[activeIdx]; if (cur) onSelect(cur.result, cur.group); }
else if (e.key === "Escape") { e.preventDefault(); onClose(); }
};
let running = -1; // 高亮索引在扁平列表中的全局编号,跨组连续
return html`
<div class="pal-wrap" role="presentation" onClick=${e => { if (e.target === e.currentTarget) onClose(); }}>
<div class="pal" role="dialog" aria-modal="true" aria-label="全局搜索">
<div class="pal-search">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input ref=${inputRef} value=${query}
placeholder="搜索产品 / 订单 / 客户 / 操作…"
role="combobox" aria-expanded="true" aria-controls="pal-listbox" aria-autocomplete="list"
aria-activedescendant=${flat[activeIdx] ? "pal-opt-" + activeIdx : undefined}
onChange=${e => { setQuery(e.target.value); setActiveIdx(0); }}
onKeyDown=${onKeyDown} />
<span class="kbd">Esc</span>
</div>
<div class="pal-list" id="pal-listbox" role="listbox" ref=${listRef}>
${flat.length === 0 && html`
<div class="pal-empty">
${query.trim() ? "没有匹配的结果" : "开始键入以搜索,或稍后回来看看最近访问"}
</div>`}
${groups.map(g => html`
<div key=${g.label}>
<div class="pal-group-head">${g.label} · ${g.results.length}</div>
${g.results.map(r => {
running += 1;
const i = running;
return html`
<button type="button" key=${g.label + ":" + r.id} id=${"pal-opt-" + i} role="option"
aria-selected=${i === activeIdx}
data-active=${i === activeIdx ? "true" : "false"}
class=${"pal-item" + (i === activeIdx ? " on" : "")}
onMouseEnter=${() => setActiveIdx(i)}
onClick=${() => onSelect(r, g.label)}>
<span>
<div class=${"title" + (r.destructive ? " danger" : "")}>${r.title}</div>
${r.subtitle && html`<div class="sub">${r.subtitle}</div>`}
</span>
<span class="tag">${r.tag}</span>
</button>`;
})}
</div>`)}
</div>
<div class="pal-foot">
<span class="hint"><span class="kbd">↑</span><span class="kbd">↓</span> 导航</span>
<span class="hint"><span class="kbd">↵</span> 选中</span>
<span class="hint"><span class="kbd">Esc</span> 关闭</span>
<span class="spacer"></span>
<span>${flat.length} 条结果</span>
</div>
</div>
</div>`;
}
function Confirm({ text, onOk, onCancel }) {
return html`
<div class="overlay" style=${{ zIndex: 60 }} 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 [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIdx, setActiveIdx] = useState(0);
const [recent, setRecent] = useState(loadRecent);
const [confirm, setConfirm] = useState(null);
const [toast, setToast] = useState("");
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
// ⌘K / Ctrl-K 唤起(当输入框获焦时 Esc 由 palette 内部处理)
useEffect(() => {
const onKey = e => {
if ((e.metaKey || e.ctrlKey) && e.key && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(o => !o);
setQuery("");
setActiveIdx(0);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 空关键字 → 显示「最近访问」;否则跨资源过滤 + 分组截断
const groups = useMemo(() => {
if (!query.trim()) {
return recent.length > 0 ? [{ label: "最近访问", results: recent }] : [];
}
return groupResults(SOURCES, query, LIMIT_PER_GROUP);
}, [query, recent]);
const pushRecent = r => {
setRecent(prev => {
const next = [r, ...prev.filter(x => x.id !== r.id)].slice(0, RECENT_MAX);
saveRecent(next);
return next;
});
};
const close = () => { setOpen(false); setQuery(""); setActiveIdx(0); };
const trigger = (r, groupLabel) => {
if (r.destructive) {
setConfirm({
text: r.title + "?此操作不可撤销。",
onOk: () => {
if (r.id === "a05") { setRecent([]); saveRecent([]); setToast("已清空最近访问"); }
setConfirm(null);
close();
},
});
return;
}
if (r.action || groupLabel === "操作") {
setToast("已触发:" + r.title);
} else {
setToast("已跳转到 " + groupLabel + ":" + r.title);
pushRecent(r);
}
close();
};
return html`
<div class="page">
<header class="head">
<div>
<h1>命令面板 / 全局搜索</h1>
<p class="sub">按 <span class="kbd">⌘</span><span class="kbd">K</span>(或 <span class="kbd">Ctrl</span><span class="kbd">K</span>)唤起 · 跨产品 / 订单 / 客户 / 操作 检索 · ↑↓ / Enter / Esc 全键盘</p>
</div>
<button class="btn primary" onClick=${() => { setOpen(true); setQuery(""); setActiveIdx(0); }}>
打开搜索
</button>
</header>
<div class="launcher">
<h2>四个分组 · 键盘可达 · 空关键字回显最近访问</h2>
<div class="row">
<span>命中项按 <b>产品 / 订单 / 客户 / 操作</b> 分组显示,每组最多 ${LIMIT_PER_GROUP} 条;</span>
<span>「操作」组里带红字的「清空最近访问」是<b>破坏性操作</b>,会先弹确认。</span>
</div>
<div class="row">
<span>当前「最近访问」共 <b>${recent.length}</b> 条${recent.length > 0 ? "(存在 localStorage,刷新页面仍在)" : "(选中任意资源即写入)"}</span>
</div>
</div>
${open && html`<${Palette}
query=${query} setQuery=${setQuery} groups=${groups}
activeIdx=${activeIdx} setActiveIdx=${setActiveIdx}
onSelect=${trigger} onClose=${close} />`}
${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>