feat(desktop): 命令面板 ⌘K —— 键盘优先工作站入口

Pillar ① 交互骨架升级第一步:全局命令面板。

- components/CommandPalette.tsx:⌘K/Ctrl+K 唤起,搜索 + 多词子串过滤 + 分组(页面/动作)
  + 键盘上下选择 + Enter 执行 + Esc 关闭 + 选中项滚动入视野,纯前端两模式通用。
- App:全局 keydown 监听切换面板;命令清单(8 个页面跳转 + 生成报告/入库知识/新建编排动作)。
- TopBar:加「搜索与命令 ⌘K」触发 pill。

验证(Preview):⌘K/点 pill 唤起 → 输入“报告”过滤出 2 条 → 点「前往·报告生成」自动跳转
报告页并关闭面板。tsc + vite build 通过;重建 .app 重启原生窗口。

注:OS 级全局唤起(⌥Space 后台常驻 + 系统托盘)受 Wails v2 主线程模型限制(与
golang.design/x/hotkey 抢主线程冲突),留作 Wails v3 / cgo 助手的后续,不在本次。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 14:32:36 +08:00
parent 72e008bfe8
commit 84efa0a11c
3 changed files with 156 additions and 4 deletions
@@ -0,0 +1,109 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Search, CornerDownLeft, type LucideIcon } from "lucide-react";
import { cn } from "../ui";
export interface Command {
id: string;
label: string;
icon: LucideIcon;
group?: string;
keywords?: string;
run: () => void;
}
function match(c: Command, q: string): boolean {
if (!q.trim()) return true;
const hay = (c.label + " " + (c.keywords ?? "") + " " + (c.group ?? "")).toLowerCase();
return q
.toLowerCase()
.split(/\s+/)
.every((t) => hay.includes(t));
}
// CommandPalette 全局命令面板(⌘K):搜索 + 键盘上下选择 + Enter 执行 + Esc 关闭。
// 纯前端、两种运行模式通用,是"键盘优先工作站"的入口。
export function CommandPalette({ open, onClose, commands }: { open: boolean; onClose: () => void; commands: Command[] }) {
const [q, setQ] = useState("");
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const filtered = useMemo(() => commands.filter((c) => match(c, q)), [commands, q]);
useEffect(() => {
if (open) {
setQ("");
setSel(0);
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}
}, [open]);
useEffect(() => setSel(0), [q]);
useEffect(() => {
listRef.current?.querySelector('[data-sel="1"]')?.scrollIntoView({ block: "nearest" });
}, [sel]);
if (!open) return null;
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(s + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const c = filtered[sel];
if (c) {
c.run();
onClose();
}
} else if (e.key === "Escape") {
onClose();
}
};
return (
<div className="fixed inset-0 z-50 flex justify-center bg-black/55 pt-[12vh]" onClick={onClose}>
<div className="h-fit w-[560px] overflow-hidden rounded-lg border border-line bg-ink-900 shadow-card" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b border-line px-3">
<Search className="h-4 w-4 shrink-0 text-slate-500" />
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKey}
placeholder="跳转 · 搜索 · 执行动作…"
className="h-11 w-full bg-transparent text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none"
/>
<kbd className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-slate-500">esc</kbd>
</div>
<ul ref={listRef} className="max-h-80 overflow-auto p-1.5">
{filtered.length === 0 && <li className="px-3 py-6 text-center text-xs text-slate-600"></li>}
{filtered.map((c, i) => {
const Icon = c.icon;
const on = i === sel;
return (
<li
key={c.id}
data-sel={on ? "1" : "0"}
onMouseEnter={() => setSel(i)}
onClick={() => {
c.run();
onClose();
}}
className={cn("flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm", on ? "bg-brand/15 text-brand-400" : "text-slate-300 hover:bg-ink-800")}
>
<Icon className={cn("h-4 w-4 shrink-0", on ? "text-brand-400" : "text-slate-500")} />
<span>{c.label}</span>
{c.group && <span className={cn("ml-auto text-[10px]", on ? "text-brand-400/70" : "text-slate-600")}>{c.group}</span>}
{on && <CornerDownLeft className="h-3.5 w-3.5 text-slate-500" />}
</li>
);
})}
</ul>
</div>
</div>
);
}