"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { filterCommands, globalCommands, moveHighlight, projectCommands, type Command, type ToolCommandInfo, } from "@/lib/command/palette"; import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { api } from "@/lib/api/client"; // 从 pathname 抽取当前 projectId(/projects//...)。 function projectIdFromPath(pathname: string): string | null { const m = pathname.match(/^\/projects\/([^/]+)/); return m?.[1] ?? null; } interface CommandPaletteProps { pathname: string; } // 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。 // 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=dialog/listbox)。 export function CommandPalette({ pathname }: CommandPaletteProps) { const router = useRouter(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [highlight, setHighlight] = useState(0); const inputRef = useRef(null); const dialogRef = useRef(null); // 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。 const [tools, setTools] = useState([]); const toolsLoadedRef = useRef(false); const projectId = projectIdFromPath(pathname); const commands = useMemo( () => projectId ? [...projectCommands(projectId, tools), ...globalCommands()] : globalCommands(), [projectId, tools], ); const results = useMemo( () => filterCommands(commands, query), [commands, query], ); const close = useCallback((): void => { setOpen(false); setQuery(""); setHighlight(0); }, []); const run = useCallback( (cmd: Command | undefined): void => { if (!cmd) return; close(); if (cmd.href) router.push(cmd.href); }, [close, router], ); // ⌘K / Ctrl+K 全局开关。 useEffect(() => { const onKey = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((prev) => !prev); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // 打开时聚焦输入框;切查询重置高亮。 useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); // 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen- 命令。 useEffect(() => { if (!open || !projectId || toolsLoadedRef.current) return; toolsLoadedRef.current = true; let cancelled = false; void (async () => { try { const { data } = await api.GET("/skills/toolbox"); if (cancelled || !data?.tools) return; setTools( data.tools .filter((t) => !t.is_legacy) .map((t) => ({ key: t.key, title: t.title, genre: t.genre })), ); } catch { // 静默降级:命令面板照常工作,仅缺生成器动作。 } })(); return () => { cancelled = true; }; }, [open, projectId]); useEffect(() => { setHighlight(0); }, [query]); if (!open) return null; const onInputKey = (e: React.KeyboardEvent): void => { if (e.key === "Escape") { e.preventDefault(); close(); } else if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => moveHighlight(h, 1, results.length)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => moveHighlight(h, -1, results.length)); } else if (e.key === "Enter") { e.preventDefault(); run(results[highlight]); } }; return (
e.stopPropagation()} onKeyDown={(e) => { // focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。 if (dialogRef.current) handleTabTrap(dialogRef.current, e); }} > setQuery(e.target.value)} onKeyDown={onInputKey} placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)" aria-label="命令搜索" aria-controls="command-list" className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none" />
    {results.length === 0 ? (
  • 无匹配命令
  • ) : ( results.map((cmd, i) => (
  • setHighlight(i)} onClick={() => run(cmd)} className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${ i === highlight ? "bg-[var(--color-cinnabar-wash)] text-cinnabar" : "text-ink" }`} > {cmd.title} {cmd.group}
  • )) )}
); }