Phase 11.1 對話式容器: - ConversationalView 雙欄佈局 (左側列表 + 右側詳情) - ApprovalThreadItem 風險等級 + 相對時間顯示 - SSE 即時更新整合 Phase 11.2 批次處理: - BatchModeSelector 組件 (全部接受/逐一審核/CRITICAL Only) - POST /api/v1/approvals/bulk-approve API 端點 - CRITICAL + DESTRUCTIVE 安全過濾 (禁止批次核准) Phase 11.4 鍵盤快捷鍵: - useKeyboardShortcuts hook (Y/N/方向鍵/Esc) - Y 鍵長按 2 秒核准 + 頂部進度指示器 - 快捷鍵說明 Modal (Y/N 高亮顯示) i18n: 100% next-intl 覆蓋 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
209 lines
4.9 KiB
TypeScript
209 lines
4.9 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* useKeyboardShortcuts - 全局鍵盤快捷鍵 Hook
|
|
* ===========================================
|
|
* Phase 11: #56 鍵盤快捷鍵支援
|
|
*
|
|
* Features:
|
|
* - Y: 長按核准 (需按住指定時間)
|
|
* - N: 拒絕
|
|
* - 方向鍵: 導航
|
|
* - ?: 顯示快捷鍵說明
|
|
* - Esc: 關閉面板
|
|
*
|
|
* 安全機制:
|
|
* - Y 鍵需要長按,防止誤觸
|
|
* - 輸入框內不觸發
|
|
*/
|
|
|
|
import { useEffect, useCallback, useRef, useState } from 'react'
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
export interface KeyboardShortcutsConfig {
|
|
/** 長按確認時間 (毫秒),預設 2000ms */
|
|
holdDuration?: number
|
|
/** Y 鍵長按核准回調 */
|
|
onApprove?: () => void
|
|
/** N 鍵拒絕回調 */
|
|
onReject?: () => void
|
|
/** 上/左 導航回調 */
|
|
onPrev?: () => void
|
|
/** 下/右 導航回調 */
|
|
onNext?: () => void
|
|
/** ? 顯示快捷鍵說明 */
|
|
onShowShortcuts?: () => void
|
|
/** Esc 關閉回調 */
|
|
onClose?: () => void
|
|
/** 是否啟用 */
|
|
enabled?: boolean
|
|
}
|
|
|
|
export interface UseKeyboardShortcutsReturn {
|
|
/** Y 鍵是否正在按住 */
|
|
isYKeyHolding: boolean
|
|
/** Y 鍵長按進度 (0-100) */
|
|
yKeyProgress: number
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hook
|
|
// =============================================================================
|
|
|
|
export function useKeyboardShortcuts(
|
|
config: KeyboardShortcutsConfig
|
|
): UseKeyboardShortcutsReturn {
|
|
const {
|
|
holdDuration = 2000,
|
|
onApprove,
|
|
onReject,
|
|
onPrev,
|
|
onNext,
|
|
onShowShortcuts,
|
|
onClose,
|
|
enabled = true,
|
|
} = config
|
|
|
|
const [isYKeyHolding, setIsYKeyHolding] = useState(false)
|
|
const [yKeyProgress, setYKeyProgress] = useState(0)
|
|
|
|
const yKeyStartTime = useRef<number | null>(null)
|
|
const yKeyAnimationFrame = useRef<number | null>(null)
|
|
const yKeyTriggered = useRef(false)
|
|
|
|
// 更新 Y 鍵進度
|
|
const updateYKeyProgress = useCallback(() => {
|
|
if (!yKeyStartTime.current) return
|
|
|
|
const elapsed = Date.now() - yKeyStartTime.current
|
|
const progress = Math.min((elapsed / holdDuration) * 100, 100)
|
|
setYKeyProgress(progress)
|
|
|
|
if (progress >= 100 && !yKeyTriggered.current) {
|
|
yKeyTriggered.current = true
|
|
onApprove?.()
|
|
// 重置狀態
|
|
setIsYKeyHolding(false)
|
|
setYKeyProgress(0)
|
|
yKeyStartTime.current = null
|
|
return
|
|
}
|
|
|
|
if (progress < 100) {
|
|
yKeyAnimationFrame.current = requestAnimationFrame(updateYKeyProgress)
|
|
}
|
|
}, [holdDuration, onApprove])
|
|
|
|
// Y 鍵按下
|
|
const handleYKeyDown = useCallback(() => {
|
|
if (yKeyStartTime.current) return // 已經在按住
|
|
|
|
yKeyStartTime.current = Date.now()
|
|
yKeyTriggered.current = false
|
|
setIsYKeyHolding(true)
|
|
setYKeyProgress(0)
|
|
yKeyAnimationFrame.current = requestAnimationFrame(updateYKeyProgress)
|
|
}, [updateYKeyProgress])
|
|
|
|
// Y 鍵釋放
|
|
const handleYKeyUp = useCallback(() => {
|
|
if (yKeyAnimationFrame.current) {
|
|
cancelAnimationFrame(yKeyAnimationFrame.current)
|
|
}
|
|
yKeyStartTime.current = null
|
|
setIsYKeyHolding(false)
|
|
setYKeyProgress(0)
|
|
}, [])
|
|
|
|
// 主要鍵盤事件處理
|
|
useEffect(() => {
|
|
if (!enabled) return
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// 忽略輸入框內的按鍵
|
|
if (
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
(e.target as HTMLElement).isContentEditable
|
|
) {
|
|
return
|
|
}
|
|
|
|
// 忽略組合鍵
|
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
return
|
|
}
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case 'y':
|
|
e.preventDefault()
|
|
handleYKeyDown()
|
|
break
|
|
|
|
case 'n':
|
|
e.preventDefault()
|
|
onReject?.()
|
|
break
|
|
|
|
case 'arrowup':
|
|
case 'arrowleft':
|
|
e.preventDefault()
|
|
onPrev?.()
|
|
break
|
|
|
|
case 'arrowdown':
|
|
case 'arrowright':
|
|
e.preventDefault()
|
|
onNext?.()
|
|
break
|
|
|
|
case '?':
|
|
e.preventDefault()
|
|
onShowShortcuts?.()
|
|
break
|
|
|
|
case 'escape':
|
|
e.preventDefault()
|
|
onClose?.()
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
if (e.key.toLowerCase() === 'y') {
|
|
handleYKeyUp()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
window.addEventListener('keyup', handleKeyUp)
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
window.removeEventListener('keyup', handleKeyUp)
|
|
|
|
// 清理動畫
|
|
if (yKeyAnimationFrame.current) {
|
|
cancelAnimationFrame(yKeyAnimationFrame.current)
|
|
}
|
|
}
|
|
}, [
|
|
enabled,
|
|
handleYKeyDown,
|
|
handleYKeyUp,
|
|
onReject,
|
|
onPrev,
|
|
onNext,
|
|
onShowShortcuts,
|
|
onClose,
|
|
])
|
|
|
|
return {
|
|
isYKeyHolding,
|
|
yKeyProgress,
|
|
}
|
|
}
|