diff --git a/apps/api/src/api/v1/approvals.py b/apps/api/src/api/v1/approvals.py index 603a8d55..6f1a48e6 100644 --- a/apps/api/src/api/v1/approvals.py +++ b/apps/api/src/api/v1/approvals.py @@ -46,6 +46,9 @@ from src.models.approval import ( ApprovalRequest, ApprovalRequestCreate, ApprovalRequestResponse, + BulkApproveRequest, + BulkApproveResponse, + BulkApproveResult, PendingApprovalsResponse, RejectRequest, SignRequest, @@ -775,6 +778,181 @@ async def reject_approval( return ApprovalRequestResponse.from_approval(approval) +# ============================================================================= +# POST /api/v1/approvals/bulk-approve (Phase 11: 批次處理) +# ============================================================================= + +@router.post( + "/bulk-approve", + response_model=BulkApproveResponse, + summary="批次簽核", + description="批次簽核多個授權請求。CRITICAL 風險或 DESTRUCTIVE 資料影響的請求將被跳過。", +) +async def bulk_approve( + request: BulkApproveRequest, + background_tasks: BackgroundTasks, +) -> BulkApproveResponse: + """ + 批次簽核授權請求 (Phase 11) + + 安全限制 (不可繞過): + - CRITICAL 風險: 跳過,需單獨審核 + - DESTRUCTIVE 資料影響: 跳過,需單獨審核 + + Args: + request: 批次簽核請求 + + Returns: + BulkApproveResponse: 批次處理結果 + """ + from src.models.approval import RiskLevel + + service = get_approval_service() + timeline = get_timeline_service() + + results: list[BulkApproveResult] = [] + succeeded = 0 + failed = 0 + skipped = 0 + + for approval_id_str in request.approval_ids: + try: + approval_id = UUID(approval_id_str) + + # 取得 Approval 詳情 + approval = await service.get_approval_by_id(approval_id) + + if approval is None: + results.append(BulkApproveResult( + approval_id=approval_id_str, + success=False, + message="Not found", + )) + failed += 1 + continue + + # 🔴 安全限制: CRITICAL 禁止批次核准 + if approval.risk_level == RiskLevel.CRITICAL: + results.append(BulkApproveResult( + approval_id=approval_id_str, + success=False, + message="CRITICAL risk requires individual review", + )) + skipped += 1 + logger.warning( + "bulk_approve_skipped_critical", + approval_id=approval_id_str, + risk_level=approval.risk_level.value, + ) + continue + + # 🔴 安全限制: DESTRUCTIVE 禁止批次核准 + if approval.blast_radius and approval.blast_radius.get("data_impact") == "DESTRUCTIVE": + results.append(BulkApproveResult( + approval_id=approval_id_str, + success=False, + message="DESTRUCTIVE data impact requires individual review", + )) + skipped += 1 + logger.warning( + "bulk_approve_skipped_destructive", + approval_id=approval_id_str, + data_impact="DESTRUCTIVE", + ) + continue + + # 執行簽核 + signed_approval, message, execution_triggered = await service.sign_approval( + approval_id=approval_id, + signer_id=request.signer_id, + signer_name=request.signer_name, + comment=request.comment or "Bulk approved", + ) + + if signed_approval is None or "Cannot sign" in message or "already signed" in message: + results.append(BulkApproveResult( + approval_id=approval_id_str, + success=False, + message=message, + )) + failed += 1 + continue + + # 成功簽核 + results.append(BulkApproveResult( + approval_id=approval_id_str, + success=True, + message=message, + execution_triggered=execution_triggered, + )) + succeeded += 1 + + # Timeline event + await timeline.add_event( + event_type="human", + status="success", + title=f"[批次] {request.signer_name} 簽核成功", + actor=request.signer_name, + actor_role="signer", + risk_level=signed_approval.risk_level.value, + approval_id=approval_id_str, + ) + + # 如果觸發執行,加入背景任務 + if execution_triggered: + background_tasks.add_task(execute_approved_action, signed_approval) + + # 更新關聯的 Incident 狀態 + incident_id = signed_approval.metadata.get("incident_id") if signed_approval.metadata else None + if incident_id: + proposal_svc = get_proposal_service() + await proposal_svc.resolve_incident_after_approval( + incident_id=incident_id, + approval_id=approval_id_str, + ) + + # SSE: 發布事件 + event_action = "approved" if execution_triggered else "signed" + asyncio.create_task(_publish_approval_event(event_action, signed_approval)) + + except ValueError: + results.append(BulkApproveResult( + approval_id=approval_id_str, + success=False, + message="Invalid UUID format", + )) + failed += 1 + except Exception as e: + logger.error( + "bulk_approve_error", + approval_id=approval_id_str, + error=str(e), + ) + results.append(BulkApproveResult( + approval_id=approval_id_str, + success=False, + message=f"Error: {str(e)}", + )) + failed += 1 + + logger.info( + "bulk_approve_completed", + total=len(request.approval_ids), + succeeded=succeeded, + failed=failed, + skipped=skipped, + signer_name=request.signer_name, + ) + + return BulkApproveResponse( + total=len(request.approval_ids), + succeeded=succeeded, + failed=failed, + skipped=skipped, + results=results, + ) + + # ============================================================================= # SSE Event Publishing (Phase 15: Polling → SSE) # ============================================================================= diff --git a/apps/api/src/models/approval.py b/apps/api/src/models/approval.py index dc08b747..853b4419 100644 --- a/apps/api/src/models/approval.py +++ b/apps/api/src/models/approval.py @@ -270,3 +270,38 @@ class PendingApprovalsResponse(BaseModel): """待簽核清單回應""" count: int approvals: list[ApprovalRequestResponse] + + +# ============================================================================= +# Phase 11: 批次處理 (Bulk Approval) +# ============================================================================= + +class BulkApproveRequest(BaseModel): + """ + 批次簽核請求 (Phase 11) + + 安全限制: + - CRITICAL 風險禁止批次核准 + - DESTRUCTIVE 資料影響禁止批次核准 + """ + approval_ids: list[str] = Field(..., description="要批次簽核的 Approval ID 列表") + signer_id: str = Field(..., description="簽核者 ID") + signer_name: str = Field(..., description="簽核者名稱") + comment: str | None = Field(default=None, description="批次簽核備註") + + +class BulkApproveResult(BaseModel): + """單個批次簽核結果""" + approval_id: str + success: bool + message: str + execution_triggered: bool = False + + +class BulkApproveResponse(BaseModel): + """批次簽核回應""" + total: int = Field(..., description="請求處理的總數") + succeeded: int = Field(..., description="成功簽核數") + failed: int = Field(..., description="失敗數") + skipped: int = Field(..., description="跳過數 (CRITICAL/DESTRUCTIVE)") + results: list[BulkApproveResult] = Field(..., description="各個 Approval 的處理結果") diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ed73729b..1c819711 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -17,7 +17,9 @@ "clear": "Clear", "refresh": "Refresh", "viewDetails": "View Details", - "later": "Later" + "later": "Later", + "keyboardShortcuts": "Keyboard Shortcuts", + "showShortcuts": "Show Shortcuts" }, "brand": { "name": "AWOOOI", @@ -258,7 +260,27 @@ "signComment": "Sign comment (optional)", "enterComment": "Enter comment...", "noApprovals": "No pending approvals", - "fetchError": "Failed to fetch approvals" + "fetchError": "Failed to fetch approvals", + "noPendingApprovals": "No pending approvals", + "selectApproval": "Select an approval to view details", + "previousApproval": "Previous", + "nextApproval": "Next", + "holdToApproveHint": "Hold button to approve or reject", + "holdYToApprove": "Hold Y to approve (2s)", + "pressNToReject": "Press N to reject", + "justNow": "just now", + "minutesAgo": "{count}m ago", + "hoursAgo": "{count}h ago", + "daysAgo": "{count}d ago", + "batch": { + "title": "Batch Mode", + "bulkApprove": "Accept All", + "sequential": "Review One by One", + "criticalOnly": "CRITICAL Only", + "eligible": "eligible", + "items": "items", + "securityNote": "CRITICAL risk and DESTRUCTIVE data impact items require individual review." + } }, "risk": { "low": "LOW RISK", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index c081e75c..f7a3d70a 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -17,7 +17,9 @@ "clear": "清除", "refresh": "重新整理", "viewDetails": "檢視詳情", - "later": "稍後" + "later": "稍後", + "keyboardShortcuts": "鍵盤快捷鍵", + "showShortcuts": "顯示快捷鍵" }, "brand": { "name": "AWOOOI", @@ -258,7 +260,27 @@ "signComment": "簽核備註 (選填)", "enterComment": "輸入備註...", "noApprovals": "目前沒有待簽核項目", - "fetchError": "無法取得授權清單" + "fetchError": "無法取得授權清單", + "noPendingApprovals": "目前無待授權項目", + "selectApproval": "請選擇一個待授權項目", + "previousApproval": "上一個項目", + "nextApproval": "下一個項目", + "holdToApproveHint": "長按按鈕以批准或拒絕", + "holdYToApprove": "長按 Y 鍵核准 (2秒)", + "pressNToReject": "按 N 鍵拒絕", + "justNow": "剛剛", + "minutesAgo": "{count} 分鐘前", + "hoursAgo": "{count} 小時前", + "daysAgo": "{count} 天前", + "batch": { + "title": "批次處理模式", + "bulkApprove": "全部接受", + "sequential": "逐一審核", + "criticalOnly": "僅顯示 CRITICAL", + "eligible": "項可批次", + "items": "項", + "securityNote": "CRITICAL 風險與 DESTRUCTIVE 資料影響的項目需單獨審核,無法批次核准。" + } }, "risk": { "low": "低風險", diff --git a/apps/web/src/components/approval/approval-thread-item.tsx b/apps/web/src/components/approval/approval-thread-item.tsx new file mode 100644 index 00000000..6898498d --- /dev/null +++ b/apps/web/src/components/approval/approval-thread-item.tsx @@ -0,0 +1,186 @@ +'use client' + +/** + * ApprovalThreadItem - 對話列表中的單一審核項目 + * ================================================ + * Phase 11: #48 ApprovalThread 子組件 + * + * Features: + * - 顯示風險等級、標題、時間戳 + * - 選中狀態視覺回饋 + * - 懸停效果 + * - Nothing.tech 風格 + * + * i18n: 100% next-intl + */ + +import { useMemo } from 'react' +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { StatusOrb } from '@/components/ui/status-orb' +import type { ApprovalRequest } from './approval-card' +import { Clock, AlertTriangle, Shield, Zap } from 'lucide-react' + +// ============================================================================= +// Types +// ============================================================================= + +interface ApprovalThreadItemProps { + approval: ApprovalRequest + isSelected: boolean + onSelect: () => void +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * 風險等級對應的顏色和圖示 + */ +function getRiskConfig(riskLevel: ApprovalRequest['riskLevel']) { + switch (riskLevel) { + case 'critical': + return { + color: 'text-status-critical', + bgColor: 'bg-status-critical/10', + borderColor: 'border-status-critical/30', + icon: AlertTriangle, + orbStatus: 'critical' as const, + } + case 'high': + return { + color: 'text-status-warning', + bgColor: 'bg-status-warning/10', + borderColor: 'border-status-warning/30', + icon: Shield, + orbStatus: 'warning' as const, + } + case 'medium': + return { + color: 'text-claw-blue', + bgColor: 'bg-claw-blue/10', + borderColor: 'border-claw-blue/30', + icon: Zap, + orbStatus: 'healthy' as const, + } + case 'low': + default: + return { + color: 'text-nothing-gray-500', + bgColor: 'bg-nothing-gray-100', + borderColor: 'border-nothing-gray-200', + icon: Clock, + orbStatus: 'healthy' as const, + } + } +} + +/** + * 格式化時間戳為相對時間 + */ +function formatRelativeTime(timestamp: string, t: ReturnType): string { + const date = new Date(timestamp) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) { + return t('justNow') + } else if (diffMins < 60) { + return t('minutesAgo', { count: diffMins }) + } else if (diffHours < 24) { + return t('hoursAgo', { count: diffHours }) + } else { + return t('daysAgo', { count: diffDays }) + } +} + +// ============================================================================= +// Component +// ============================================================================= + +export function ApprovalThreadItem({ + approval, + isSelected, + onSelect, +}: ApprovalThreadItemProps) { + const t = useTranslations('approval') + + const riskConfig = useMemo(() => getRiskConfig(approval.riskLevel), [approval.riskLevel]) + const RiskIcon = riskConfig.icon + + const relativeTime = useMemo( + () => formatRelativeTime(approval.requestedAt, t), + [approval.requestedAt, t] + ) + + // 提取動作類型摘要 + const actionSummary = useMemo(() => { + // 截取 action 的前 30 個字元作為摘要 + const action = approval.action || '' + return action.length > 30 ? action.slice(0, 30) + '...' : action + }, [approval.action]) + + return ( + + ) +} diff --git a/apps/web/src/components/approval/batch-mode-selector.tsx b/apps/web/src/components/approval/batch-mode-selector.tsx new file mode 100644 index 00000000..1600bee5 --- /dev/null +++ b/apps/web/src/components/approval/batch-mode-selector.tsx @@ -0,0 +1,305 @@ +'use client' + +/** + * BatchModeSelector - 批次處理模式選擇器 + * ======================================= + * Phase 11: #50 批次處理功能 + * + * Features: + * - 全部接受: 批次核准所有低/中風險 + * - 逐一審核: 依序審核 + * - 僅顯示 CRITICAL: 過濾高風險 + * + * 安全限制: + * - CRITICAL + DESTRUCTIVE 禁止批次核准 + * + * i18n: 100% next-intl + */ + +import { useState, useMemo, useCallback } from 'react' +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { + useApprovalStore, + usePendingApprovals, + toFrontendApproval, +} from '@/stores/approval.store' +import { StatusOrb } from '@/components/ui/status-orb' +import { + CheckCircle2, + ListFilter, + AlertTriangle, + Loader2, + Shield, +} from 'lucide-react' +// ApprovalRequest type used implicitly via toFrontendApproval + +// ============================================================================= +// Types +// ============================================================================= + +export type BatchMode = 'sequential' | 'filter_critical' | 'bulk_approve' + +interface BatchModeSelectorProps { + className?: string + /** 批次核准完成後的回調 */ + onBulkComplete?: (succeeded: number, failed: number, skipped: number) => void + /** 簽核者資訊 */ + signerId?: string + signerName?: string +} + +interface BulkApproveResult { + approval_id: string + success: boolean + message: string + execution_triggered: boolean +} + +interface BulkApproveResponse { + total: number + succeeded: number + failed: number + skipped: number + results: BulkApproveResult[] +} + +// ============================================================================= +// Component +// ============================================================================= + +export function BatchModeSelector({ + className, + onBulkComplete, + signerId = 'user-001', + signerName = 'Demo User', +}: BatchModeSelectorProps) { + const t = useTranslations('approval') + const tBatch = useTranslations('approval.batch') + + const pendingApprovals = usePendingApprovals() + const { fetchPending } = useApprovalStore() + + const [mode, setMode] = useState('sequential') + const [isProcessing, setIsProcessing] = useState(false) + + // 轉換為前端格式 + const approvals = useMemo(() => { + return pendingApprovals.map((a) => toFrontendApproval(a)) + }, [pendingApprovals]) + + // 統計各風險等級數量 + const stats = useMemo(() => { + const result = { + total: approvals.length, + low: 0, + medium: 0, + high: 0, + critical: 0, + canBulkApprove: 0, + } + + approvals.forEach((a) => { + switch (a.riskLevel) { + case 'low': + result.low++ + result.canBulkApprove++ + break + case 'medium': + result.medium++ + result.canBulkApprove++ + break + case 'high': + result.high++ + // HIGH 風險允許批次,但 DESTRUCTIVE 不允許 + if (a.blastRadius.dataImpact !== 'DESTRUCTIVE') { + result.canBulkApprove++ + } + break + case 'critical': + result.critical++ + // CRITICAL 禁止批次 + break + } + }) + + return result + }, [approvals]) + + // 批次核准處理 + const handleBulkApprove = useCallback(async () => { + if (stats.canBulkApprove === 0) return + + setIsProcessing(true) + + try { + // 取得可批次核准的 ID 列表 + const bulkApproveIds = approvals + .filter((a) => { + // 排除 CRITICAL + if (a.riskLevel === 'critical') return false + // 排除 DESTRUCTIVE + if (a.blastRadius.dataImpact === 'DESTRUCTIVE') return false + return true + }) + .map((a) => a.id) + + const response = await fetch('/api/v1/approvals/bulk-approve', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + approval_ids: bulkApproveIds, + signer_id: signerId, + signer_name: signerName, + comment: 'Bulk approved via ConversationalView', + }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const result: BulkApproveResponse = await response.json() + + // 刷新列表 + await fetchPending() + + // 回調通知 + onBulkComplete?.(result.succeeded, result.failed, result.skipped) + + } catch (error) { + console.error('Bulk approve failed:', error) + } finally { + setIsProcessing(false) + } + }, [approvals, stats.canBulkApprove, signerId, signerName, fetchPending, onBulkComplete]) + + return ( +
+ {/* 統計概覽 */} +
+

+ {tBatch('title')} +

+
+ {stats.total} {t('pendingApprovals')} +
+
+ + {/* 風險等級分佈 */} +
+
+ + + LOW: {stats.low} + +
+
+ + + MED: {stats.medium} + +
+
+ + + HIGH: {stats.high} + +
+
+ + + CRIT: {stats.critical} + +
+
+ + {/* 模式選擇按鈕 */} +
+ {/* 全部接受 (批次) */} + + + {/* 逐一審核 */} + + + {/* 僅顯示 CRITICAL */} + +
+ + {/* 安全提示 */} +
+ +

+ {tBatch('securityNote')} +

+
+
+ ) +} diff --git a/apps/web/src/components/approval/conversational-view.tsx b/apps/web/src/components/approval/conversational-view.tsx new file mode 100644 index 00000000..679f78c1 --- /dev/null +++ b/apps/web/src/components/approval/conversational-view.tsx @@ -0,0 +1,345 @@ +'use client' + +/** + * ConversationalView - 對話式 AI 審核介面 + * ======================================== + * Phase 11: ChatGPT / GitHub Copilot 風格 + * + * Features: + * - 左側: ApprovalThread 對話列表 + * - 右側: ApprovalCard 詳情面板 + * - 鍵盤快捷鍵: Y/N/方向鍵 + * - 響應式: Desktop 雙欄 / Mobile 堆疊 + * + * i18n: 100% next-intl + */ + +import { useState, useCallback, useEffect, useMemo } from 'react' +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { + useApprovalStore, + usePendingApprovals, + toFrontendApproval, +} from '@/stores/approval.store' +import { useApprovalSSE } from '@/hooks/useApprovalSSE' +import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' +import { ApprovalCard } from './approval-card' +import { ApprovalThreadItem } from './approval-thread-item' +import { StatusOrb } from '@/components/ui/status-orb' +import { + MessageSquare, + ChevronLeft, + ChevronRight, + Keyboard, + Wifi, + WifiOff, +} from 'lucide-react' + +// ============================================================================= +// Types +// ============================================================================= + +interface ConversationalViewProps { + className?: string + /** 簽核者 ID */ + signerId?: string + /** 簽核者名稱 */ + signerName?: string +} + +// ============================================================================= +// Component +// ============================================================================= + +export function ConversationalView({ + className, + signerId = 'user-001', + signerName = 'Demo User', +}: ConversationalViewProps) { + const t = useTranslations('approval') + const tCommon = useTranslations('common') + + // Store + const { signApproval, rejectApproval } = useApprovalStore() + const pendingApprovals = usePendingApprovals() + + // SSE 連線狀態 + const { isConnected, status: sseStatus } = useApprovalSSE({ autoConnect: true }) + + // 選中的 Approval + const [selectedId, setSelectedId] = useState(null) + const [showShortcuts, setShowShortcuts] = useState(false) + + // 轉換為前端格式 + const approvals = useMemo(() => { + return pendingApprovals.map((a) => toFrontendApproval(a)) + }, [pendingApprovals]) + + // 當前選中的 index + const selectedIndex = useMemo(() => { + if (!selectedId) return -1 + return approvals.findIndex((a) => a.id === selectedId) + }, [selectedId, approvals]) + + // 選中的 Approval 詳情 + const selectedApproval = useMemo(() => { + return approvals.find((a) => a.id === selectedId) || null + }, [selectedId, approvals]) + + // 自動選中第一個 + useEffect(() => { + if (!selectedId && approvals.length > 0) { + setSelectedId(approvals[0].id) + } + // 如果選中的已被處理,選下一個 + if (selectedId && !approvals.find((a) => a.id === selectedId)) { + if (approvals.length > 0) { + setSelectedId(approvals[0].id) + } else { + setSelectedId(null) + } + } + }, [approvals, selectedId]) + + // 導航函數 + const goToPrev = useCallback(() => { + if (selectedIndex > 0) { + setSelectedId(approvals[selectedIndex - 1].id) + } + }, [selectedIndex, approvals]) + + const goToNext = useCallback(() => { + if (selectedIndex < approvals.length - 1) { + setSelectedId(approvals[selectedIndex + 1].id) + } + }, [selectedIndex, approvals]) + + // 簽核/拒絕處理 + const handleApprove = useCallback(async (id: string) => { + await signApproval(id, signerId, signerName, 'Approved via Conversational UI') + }, [signApproval, signerId, signerName]) + + const handleReject = useCallback(async (id: string) => { + await rejectApproval(id, signerId, signerName, 'Rejected via Conversational UI') + }, [rejectApproval, signerId, signerName]) + + // 鍵盤快捷鍵 (Phase 11.4: Y/N 支援) + const { isYKeyHolding, yKeyProgress } = useKeyboardShortcuts({ + holdDuration: 2000, + onApprove: useCallback(() => { + if (selectedId) { + handleApprove(selectedId) + } + }, [selectedId, handleApprove]), + onReject: useCallback(() => { + if (selectedId) { + handleReject(selectedId) + } + }, [selectedId, handleReject]), + onPrev: goToPrev, + onNext: goToNext, + onShowShortcuts: useCallback(() => setShowShortcuts((s) => !s), []), + onClose: useCallback(() => setShowShortcuts(false), []), + enabled: selectedApproval !== null, + }) + + return ( +
+ {/* 左側: 對話列表 */} +
+ {/* 列表 Header */} +
+
+
+ +

+ {t('pendingApprovals')} +

+
+
+ {/* SSE 連線狀態 */} +
+ {isConnected ? ( + + ) : ( + + )} + {approvals.length} +
+ {/* 快捷鍵提示 */} + +
+
+ + {/* 導航提示 */} + {approvals.length > 1 && ( +
+ + {selectedIndex + 1} / {approvals.length} + +
+ + +
+
+ )} +
+ + {/* 列表內容 */} +
+ {approvals.length === 0 ? ( +
+ +

{t('noPendingApprovals')}

+
+ ) : ( + approvals.map((approval) => ( + setSelectedId(approval.id)} + /> + )) + )} +
+
+ + {/* 右側: 詳情面板 */} +
+ {/* Y 鍵長按進度指示器 */} + {isYKeyHolding && ( +
+
+
+ )} + + {selectedApproval ? ( +
+ +
+ ) : ( +
+
+ +

{t('selectApproval')}

+
+
+ )} +
+ + {/* 快捷鍵說明 Modal */} + {showShortcuts && ( +
setShowShortcuts(false)} + > +
e.stopPropagation()} + > +

+ {tCommon('keyboardShortcuts')} +

+
+ + + + + + +
+

+ {t('holdToApproveHint')} +

+
+
+ )} +
+ ) +} + +// ============================================================================= +// Sub-components +// ============================================================================= + +function ShortcutRow({ + keys, + label, + highlight, +}: { + keys: string[] + label: string + highlight?: boolean +}) { + return ( +
+
+ {keys.map((key) => ( + + {key} + + ))} +
+ + {label} + +
+ ) +} diff --git a/apps/web/src/components/approval/index.ts b/apps/web/src/components/approval/index.ts index 2cd7d373..f2e22fcb 100644 --- a/apps/web/src/components/approval/index.ts +++ b/apps/web/src/components/approval/index.ts @@ -17,6 +17,11 @@ export { export { LiveApprovalPanel } from './live-approval-panel' +// Phase 11: 對話式 AI UI +export { ConversationalView } from './conversational-view' +export { ApprovalThreadItem } from './approval-thread-item' +export { BatchModeSelector, type BatchMode } from './batch-mode-selector' + // ============================================================================= // Mock Data for Demo // ============================================================================= diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index cf2b2660..f58141d8 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -4,3 +4,4 @@ export * from './useSSE' export * from './useApprovalSSE' export * from './useIncidents' export * from './useGlobalPulseMetrics' +export * from './useKeyboardShortcuts' diff --git a/apps/web/src/hooks/useKeyboardShortcuts.ts b/apps/web/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..e6cd3dbd --- /dev/null +++ b/apps/web/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,208 @@ +'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(null) + const yKeyAnimationFrame = useRef(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, + } +} diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index b4c73d40..e4eecd3b 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -5,15 +5,36 @@ --- -## 📍 當前狀態 (2026-03-25 01:20) +## 📍 當前狀態 (2026-03-25 14:00) | 項目 | 狀態 | |------|------| -| **當前 Phase** | **Phase 10 完善** | +| **當前 Phase** | **Phase 11 對話式 AI UI/UX** | | **Day** | Day 7 | -| **下一步** | 測試簽核流程 (驗證原始內容保留) | -| **重大修復** | ✅ **簽核保留原始內容** - OpenClaw 1859893 | -| **CI/CD** | ✅ 23501633819 完成 | +| **下一步** | Phase 11.3 響應式 / 最終整合測試 | +| **重大決策** | ✅ **Phase 11.1-11.4 完成** - 對話式容器 + 批次處理 + 鍵盤快捷鍵 | +| **CI/CD** | ✅ Runner 恢復運作 | + +### ✅ 2026-03-25 Phase 11 進度 (Day 7) + +**Phase 11.1 對話式容器** ✅ +- ConversationalView 主容器 (左/右雙欄) +- ApprovalThreadItem 列表項目 (風險等級 + 相對時間) +- SSE 即時更新整合 (useApprovalSSE) + +**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 高亮) + +**Phase 11.3 響應式** ⏳ (P2 待辦) +- Desktop 雙欄已完成 +- Tablet/Mobile 待實作 ### 🔴 2026-03-25 01:20 簽核內容保留修復 @@ -63,6 +84,10 @@ | 時間 | 事件 | 負責人 | |------|------|--------| +| 2026-03-25 14:00 | **🎨 Phase 11.1-11.4 完成**: ConversationalView + BatchModeSelector + useKeyboardShortcuts (Y/N 長按支援) | Claude Code | +| 2026-03-25 11:00 | **✅ #15 SSE 改造完成 (170102a)**: Approval Polling → SSE 即時更新,新增 /api/v1/approvals/stream + useApprovalSSE hook | Claude Code | +| 2026-03-25 10:00 | **🎨 Phase 11 對話式 AI 批准**: ChatGPT 風格 + 批次處理 + 鍵盤快捷鍵 (Y/N/方向鍵) + 響應式佈局 (#47-59) | 統帥 | +| 2026-03-25 09:45 | **🕐 台北時區統一 (749b8bc)**: 11 個後端檔案改用 +8 時區 + 新增 timezone.py 工具 | Claude Code | | 2026-03-25 01:10 | **✅ CD 23501633819 部署完成**: API/Web/Worker 全部更新,Alertmanager webhook 路徑修復生效 | Claude Code | | 2026-03-25 01:05 | **🔧 NetworkPolicy DNS 修復**: CoreDNS podSelector 修正,Telegram 發送恢復 | Claude Code | | 2026-03-25 01:00 | **📝 feedback_approval_preserve_content.md**: 簽核後保留原始內容鐵律 | Claude Code |