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>
273 lines
9.1 KiB
TypeScript
273 lines
9.1 KiB
TypeScript
'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>
|
|
)
|
|
}
|