diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f445ea0e..d09ea71b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1202,5 +1202,24 @@ "eventBackupFailed": "Backup Failed", "eventApprovalEscalated": "Approval Escalated", "eventChangeApplied": "Change Applied" + }, + "commandPalette": { + "placeholder": "Search commands, pages or events...", + "noResults": "No results found", + "hint": "↑↓ Navigate Enter Select Esc Close", + "groupNav": "Navigation", + "groupActions": "Quick Actions", + "groupRecent": "Recent Events", + "actionOpenTerminal": "Open Omni-Terminal", + "actionGoHome": "Go to Command Center", + "actionGoObservability": "Go to Observability", + "actionGoAutomation": "Go to Automation", + "actionGoOperations": "Go to Operations", + "actionGoSecurity": "Go to Security & Compliance", + "actionGoKnowledge": "Go to Knowledge Hall", + "actionGoSettings": "Go to Settings", + "actionGoTerminal": "Go to Terminal", + "actionGoApprovals": "Go to Authorizations" + } } } \ No newline at end of file diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index f4499fa3..1b71b267 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1203,5 +1203,23 @@ "eventBackupFailed": "備份失敗", "eventApprovalEscalated": "審批升級", "eventChangeApplied": "變更套用" + }, + "commandPalette": { + "placeholder": "搜尋指令、頁面或事件...", + "noResults": "找不到符合結果", + "hint": "↑↓ 選擇 Enter 確認 Esc 關閉", + "groupNav": "導航", + "groupActions": "快速動作", + "groupRecent": "最近事件", + "actionOpenTerminal": "開啟 Omni-Terminal", + "actionGoHome": "前往指令中心", + "actionGoObservability": "前往可觀測性", + "actionGoAutomation": "前往自動化", + "actionGoOperations": "前往營運", + "actionGoSecurity": "前往安全合規", + "actionGoKnowledge": "前往知識殿堂", + "actionGoSettings": "前往設定", + "actionGoTerminal": "前往終端頁面", + "actionGoApprovals": "前往授權中心" } } \ No newline at end of file diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index f5e744c9..e75b4694 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' import { ToastProvider, ToastInitializer } from '@/components/ui/toast' import { OmniTerminal } from '@/components/terminal/OmniTerminal' +import { CommandPalette } from '@/components/command-palette/CommandPalette' export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -24,6 +25,7 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} + ) diff --git a/apps/web/src/components/command-palette/CommandPalette.tsx b/apps/web/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 00000000..dc1bd1ab --- /dev/null +++ b/apps/web/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,272 @@ +'use client' + +/** + * AWOOOI Command Palette + * ========================== + * ⌘K 全局指令面板 — 高斯模糊背景 + 快速導航 + 動作 + * + * 快捷鍵: ⌘K (Mac) / Ctrl+K (其他) + * Z-Index: DIALOG (70) + * + * @author Claude Code (首席架構師) + * @version 1.0.0 + * @date 2026-04-09 (台北時間) + */ + +import React, { useEffect, useRef, useState, useCallback } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter, usePathname } from 'next/navigation' +import { useLocale } from 'next-intl' +import { Search, Terminal, Home, Activity, Wrench, Shield, BookOpen, Settings, Zap, GitBranch } from 'lucide-react' +import { useTerminalStore } from '@/stores/terminal.store' +import { Z_INDEX } from '@/lib/constants/z-index' + +interface PaletteItem { + id: string + label: string + group: string + icon: React.ReactNode + action: () => void + keywords?: string[] +} + +export function CommandPalette() { + const t = useTranslations('commandPalette') + const router = useRouter() + const locale = useLocale() + const pathname = usePathname() + const { openTerminal } = useTerminalStore() + + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [activeIdx, setActiveIdx] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + + const nav = (path: string) => { + router.push(`/${locale}${path}`) + setOpen(false) + } + + const items: PaletteItem[] = [ + // 快速動作 + { + id: 'terminal', + label: t('actionOpenTerminal'), + group: t('groupActions'), + icon: , + action: () => { openTerminal(); setOpen(false) }, + keywords: ['terminal', '終端', 'omni', 'cmd', '指令'], + }, + // 導航 + { + id: 'home', + label: t('actionGoHome'), + group: t('groupNav'), + icon: , + action: () => nav(''), + keywords: ['home', '首頁', '指令中心', 'dashboard'], + }, + { + id: 'approvals', + label: t('actionGoApprovals'), + group: t('groupNav'), + icon: , + action: () => nav('/authorizations'), + keywords: ['授權', 'approve', '批准', 'authorization'], + }, + { + id: 'observability', + label: t('actionGoObservability'), + group: t('groupNav'), + icon: , + action: () => nav('/observability'), + keywords: ['observability', '可觀測性', 'monitor', '監控'], + }, + { + id: 'automation', + label: t('actionGoAutomation'), + group: t('groupNav'), + icon: , + action: () => nav('/automation'), + keywords: ['automation', '自動化', 'auto'], + }, + { + id: 'operations', + label: t('actionGoOperations'), + group: t('groupNav'), + icon: , + action: () => nav('/operations'), + keywords: ['operations', '營運', 'ops'], + }, + { + id: 'security', + label: t('actionGoSecurity'), + group: t('groupNav'), + icon: , + action: () => nav('/security-compliance'), + keywords: ['security', '安全', 'compliance', '合規'], + }, + { + id: 'knowledge', + label: t('actionGoKnowledge'), + group: t('groupNav'), + icon: , + action: () => nav('/knowledge'), + keywords: ['knowledge', '知識', '殿堂', 'kb'], + }, + { + id: 'settings', + label: t('actionGoSettings'), + group: t('groupNav'), + icon: , + action: () => nav('/settings'), + keywords: ['settings', '設定', 'config'], + }, + { + id: 'terminal-page', + label: t('actionGoTerminal'), + group: t('groupNav'), + icon: , + action: () => nav('/terminal'), + keywords: ['terminal', '終端', 'shell'], + }, + ] + + const filtered = query.trim() === '' + ? items + : items.filter(item => { + const q = query.toLowerCase() + return item.label.toLowerCase().includes(q) || + (item.keywords ?? []).some(k => k.toLowerCase().includes(q)) + }) + + // 開啟時重置 + useEffect(() => { + if (open) { + setQuery('') + setActiveIdx(0) + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open]) + + // 更新 activeIdx 時捲動 + useEffect(() => { + const el = listRef.current?.children[activeIdx] as HTMLElement | undefined + el?.scrollIntoView({ block: 'nearest' }) + }, [activeIdx]) + + // ⌘K / Ctrl+K 開啟 + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + setOpen(prev => !prev) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + // 鍵盤導航 + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIdx(i => Math.min(i + 1, filtered.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx(i => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + filtered[activeIdx]?.action() + } else if (e.key === 'Escape') { + setOpen(false) + } + }, [filtered, activeIdx]) + + // query 變更時重置 activeIdx + useEffect(() => setActiveIdx(0), [query]) + + if (!open) return null + + // 按 group 分組 + const groups: Record = {} + filtered.forEach(item => { + if (!groups[item.group]) groups[item.group] = [] + groups[item.group].push(item) + }) + + // 攤平為帶 group header 的列表(用於 activeIdx 計算) + let flatIdx = 0 + + return ( +
setOpen(false)} + > + {/* 高斯模糊背景 */} +
+ + {/* 面板 */} +
e.stopPropagation()} + > + {/* 搜尋列 */} +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('placeholder')} + style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: '#141413' }} + /> + ESC +
+ + {/* 結果列表 */} +
+ {filtered.length === 0 ? ( +
{t('noResults')}
+ ) : ( + Object.entries(groups).map(([group, groupItems]) => ( +
+
{group}
+ {groupItems.map(item => { + const idx = flatIdx++ + const isActive = idx === activeIdx + return ( +
setActiveIdx(idx)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '8px 16px', cursor: 'pointer', + background: isActive ? 'rgba(74,144,217,0.08)' : 'transparent', + transition: 'background 0.1s', + }} + > + {item.icon} + {item.label} + {isActive && ( + + )} +
+ ) + })} +
+ )) + )} +
+ + {/* 底部提示 */} +
+ {t('hint')} +
+
+
+ ) +}