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:
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LayoutDashboard, Workflow, Database, FileText, Activity, Bookmark, Boxes, Settings } from "lucide-react";
|
||||
|
||||
import { TopBar } from "./shell/TopBar";
|
||||
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
||||
@@ -10,6 +11,7 @@ import { ReportView } from "./views/ReportView";
|
||||
import { RunsView } from "./views/RunsView";
|
||||
import { Home } from "./views/Home";
|
||||
import { Placeholder } from "./views/Placeholder";
|
||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||
import { submitTask, streamTokens, streamExec, type Identity } from "./lib/api";
|
||||
import type { TaskDsl } from "./lib/dsl";
|
||||
import { emptyRun, type RunState } from "./lib/run";
|
||||
@@ -28,10 +30,40 @@ export default function App() {
|
||||
const [view, setView] = useState<ViewKey>("home");
|
||||
const [identity, setIdentity] = useState<Identity>({ userId: "wt", sessionId: "sess-ui" });
|
||||
const [run, setRun] = useState<RunState>(emptyRun);
|
||||
const [cmdOpen, setCmdOpen] = useState(false);
|
||||
const closeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const execCloseRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// 全局 ⌘K / Ctrl+K 唤起命令面板(键盘优先工作站入口)。
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setCmdOpen((o) => !o);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const go = (key: ViewKey) => () => setView(key);
|
||||
return [
|
||||
{ id: "home", label: "前往 · 工作台", icon: LayoutDashboard, group: "页面", keywords: "home dashboard 概览", run: go("home") },
|
||||
{ id: "studio", label: "前往 · 编排", icon: Workflow, group: "页面", keywords: "studio agent graph 编排 图", run: go("studio") },
|
||||
{ id: "kb", label: "前往 · 知识库", icon: Database, group: "页面", keywords: "kb rag 检索 图谱 入库", run: go("kb") },
|
||||
{ id: "report", label: "前往 · 报告生成", icon: FileText, group: "页面", keywords: "report 报告 word docx", run: go("report") },
|
||||
{ id: "runs", label: "前往 · 运行观测", icon: Activity, group: "页面", keywords: "runs trace 轨迹 观测", run: go("runs") },
|
||||
{ id: "memory", label: "前往 · 记忆", icon: Bookmark, group: "页面", keywords: "memory 画像 偏好", run: go("memory") },
|
||||
{ id: "market", label: "前往 · 市场 Packs", icon: Boxes, group: "页面", keywords: "market pack 垂直", run: go("market") },
|
||||
{ id: "admin", label: "前往 · 管理", icon: Settings, group: "页面", keywords: "admin 租户 模型 设置", run: go("admin") },
|
||||
{ id: "act-report", label: "生成报告", icon: FileText, group: "动作", keywords: "新建 report 撰写", run: go("report") },
|
||||
{ id: "act-ingest", label: "入库知识", icon: Database, group: "动作", keywords: "上传 ingest 文件", run: go("kb") },
|
||||
{ id: "act-studio", label: "新建 Agent 编排", icon: Workflow, group: "动作", keywords: "new flow", run: go("studio") },
|
||||
];
|
||||
}, []);
|
||||
|
||||
const onRun = useCallback(
|
||||
async (dsl: TaskDsl) => {
|
||||
closeRef.current?.();
|
||||
@@ -85,7 +117,7 @@ export default function App() {
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-64 opacity-60"
|
||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||
/>
|
||||
<TopBar identity={identity} setIdentity={setIdentity} />
|
||||
<TopBar identity={identity} setIdentity={setIdentity} onCommand={() => setCmdOpen(true)} />
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<LeftNav active={view} onSelect={setView} />
|
||||
<main className="min-w-0 flex-1 overflow-hidden">
|
||||
@@ -107,6 +139,7 @@ export default function App() {
|
||||
</main>
|
||||
</div>
|
||||
<BottomDrawer run={run} />
|
||||
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { User, ChevronDown } from "lucide-react";
|
||||
import { User, ChevronDown, Search as SearchIcon } from "lucide-react";
|
||||
import type { Identity } from "../lib/api";
|
||||
import { useHealth } from "../lib/health";
|
||||
import { isMacDesktop } from "../lib/desktop";
|
||||
@@ -20,7 +20,7 @@ function Light({ on, label }: { on: boolean; label: string }) {
|
||||
}
|
||||
|
||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 身份/会话(深色 + 毛玻璃)。
|
||||
export function TopBar({ identity, setIdentity }: { identity: Identity; setIdentity: (id: Identity) => void }) {
|
||||
export function TopBar({ identity, setIdentity, onCommand }: { identity: Identity; setIdentity: (id: Identity) => void; onCommand?: () => void }) {
|
||||
const h = useHealth();
|
||||
return (
|
||||
<header
|
||||
@@ -44,6 +44,16 @@ export function TopBar({ identity, setIdentity }: { identity: Identity; setIdent
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
</div>
|
||||
<button
|
||||
onClick={onCommand}
|
||||
style={NODRAG}
|
||||
className="flex items-center gap-2 rounded-md border border-line bg-ink-800 px-2.5 py-1 text-xs text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
||||
title="命令面板"
|
||||
>
|
||||
<SearchIcon className="h-3.5 w-3.5" />
|
||||
搜索与命令
|
||||
<kbd className="rounded border border-line px-1 font-mono text-[10px] text-slate-500">⌘K</kbd>
|
||||
</button>
|
||||
<div className="ml-2 flex items-center gap-3">
|
||||
<Light on={h.gateway} label="Gateway" />
|
||||
<Light on={h.db} label="DB" />
|
||||
|
||||
Reference in New Issue
Block a user