feat(web): ⌘K Command Palette — 全局指令面板 + 高斯模糊
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

- ⌘K (Mac) / Ctrl+K (其他) 開啟/關閉
- 高斯模糊背景 (backdrop-blur 8px + rgba overlay)
- 搜尋過濾:導航 9 頁 + 快速動作(開 Terminal)
- 鍵盤完整支援:↑↓ 選擇 / Enter 執行 / Esc 關閉
- 滑鼠 hover 同步 activeIdx
- 100% i18n (commandPalette namespace)
- Z-Index: DIALOG(70),掛載於 providers.tsx 全局層

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 23:28:36 +08:00
parent 5b42bd34e6
commit 89db96fc21
4 changed files with 311 additions and 0 deletions

View File

@@ -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"
}
}
}

View File

@@ -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": "前往授權中心"
}
}

View File

@@ -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 }) {
<ToastInitializer />
{children}
<OmniTerminal />
<CommandPalette />
</ToastProvider>
</QueryClientProvider>
)

View File

@@ -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<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const nav = (path: string) => {
router.push(`/${locale}${path}`)
setOpen(false)
}
const items: PaletteItem[] = [
// 快速動作
{
id: 'terminal',
label: t('actionOpenTerminal'),
group: t('groupActions'),
icon: <Terminal size={14} />,
action: () => { openTerminal(); setOpen(false) },
keywords: ['terminal', '終端', 'omni', 'cmd', '指令'],
},
// 導航
{
id: 'home',
label: t('actionGoHome'),
group: t('groupNav'),
icon: <Home size={14} />,
action: () => nav(''),
keywords: ['home', '首頁', '指令中心', 'dashboard'],
},
{
id: 'approvals',
label: t('actionGoApprovals'),
group: t('groupNav'),
icon: <Zap size={14} />,
action: () => nav('/authorizations'),
keywords: ['授權', 'approve', '批准', 'authorization'],
},
{
id: 'observability',
label: t('actionGoObservability'),
group: t('groupNav'),
icon: <Activity size={14} />,
action: () => nav('/observability'),
keywords: ['observability', '可觀測性', 'monitor', '監控'],
},
{
id: 'automation',
label: t('actionGoAutomation'),
group: t('groupNav'),
icon: <Wrench size={14} />,
action: () => nav('/automation'),
keywords: ['automation', '自動化', 'auto'],
},
{
id: 'operations',
label: t('actionGoOperations'),
group: t('groupNav'),
icon: <GitBranch size={14} />,
action: () => nav('/operations'),
keywords: ['operations', '營運', 'ops'],
},
{
id: 'security',
label: t('actionGoSecurity'),
group: t('groupNav'),
icon: <Shield size={14} />,
action: () => nav('/security-compliance'),
keywords: ['security', '安全', 'compliance', '合規'],
},
{
id: 'knowledge',
label: t('actionGoKnowledge'),
group: t('groupNav'),
icon: <BookOpen size={14} />,
action: () => nav('/knowledge'),
keywords: ['knowledge', '知識', '殿堂', 'kb'],
},
{
id: 'settings',
label: t('actionGoSettings'),
group: t('groupNav'),
icon: <Settings size={14} />,
action: () => nav('/settings'),
keywords: ['settings', '設定', 'config'],
},
{
id: 'terminal-page',
label: t('actionGoTerminal'),
group: t('groupNav'),
icon: <Terminal size={14} />,
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<string, PaletteItem[]> = {}
filtered.forEach(item => {
if (!groups[item.group]) groups[item.group] = []
groups[item.group].push(item)
})
// 攤平為帶 group header 的列表(用於 activeIdx 計算)
let flatIdx = 0
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: Z_INDEX.DIALOG, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '15vh' }}
onClick={() => setOpen(false)}
>
{/* 高斯模糊背景 */}
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)' }} />
{/* 面板 */}
<div
style={{ position: 'relative', width: '100%', maxWidth: 560, background: '#fff', borderRadius: 14, boxShadow: '0 24px 80px rgba(0,0,0,0.22)', border: '0.5px solid #e0ddd4', overflow: 'hidden' }}
onClick={e => e.stopPropagation()}
>
{/* 搜尋列 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 16px', borderBottom: '0.5px solid #f0ede5' }}>
<Search size={15} style={{ color: '#87867f', flexShrink: 0 }} />
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('placeholder')}
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: '#141413' }}
/>
<kbd style={{ fontSize: 10, background: '#f0ede5', color: '#87867f', borderRadius: 4, padding: '2px 6px', fontFamily: "'JetBrains Mono', monospace" }}>ESC</kbd>
</div>
{/* 結果列表 */}
<div ref={listRef} style={{ maxHeight: 360, overflowY: 'auto', padding: '6px 0' }}>
{filtered.length === 0 ? (
<div style={{ padding: '24px 16px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('noResults')}</div>
) : (
Object.entries(groups).map(([group, groupItems]) => (
<div key={group}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.6, color: '#87867f', padding: '6px 16px 2px' }}>{group}</div>
{groupItems.map(item => {
const idx = flatIdx++
const isActive = idx === activeIdx
return (
<div
key={item.id}
onClick={item.action}
onMouseEnter={() => 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',
}}
>
<span style={{ color: isActive ? '#4A90D9' : '#87867f', flexShrink: 0 }}>{item.icon}</span>
<span style={{ fontSize: 13, color: isActive ? '#141413' : '#555550' }}>{item.label}</span>
{isActive && (
<kbd style={{ marginLeft: 'auto', fontSize: 10, background: '#f0ede5', color: '#87867f', borderRadius: 4, padding: '2px 6px', fontFamily: "'JetBrains Mono', monospace" }}></kbd>
)}
</div>
)
})}
</div>
))
)}
</div>
{/* 底部提示 */}
<div style={{ padding: '8px 16px', borderTop: '0.5px solid #f0ede5', fontSize: 10, color: '#87867f', textAlign: 'center', letterSpacing: 0.3 }}>
{t('hint')}
</div>
</div>
</div>
)
}