feat(web): ⌘K Command Palette — 全局指令面板 + 高斯模糊
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "前往授權中心"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
272
apps/web/src/components/command-palette/CommandPalette.tsx
Normal file
272
apps/web/src/components/command-palette/CommandPalette.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user