diff --git a/apps/web/src/components/ai/openclaw-state-machine.tsx b/apps/web/src/components/ai/openclaw-state-machine.tsx index 5548568d2..1630eb272 100644 --- a/apps/web/src/components/ai/openclaw-state-machine.tsx +++ b/apps/web/src/components/ai/openclaw-state-machine.tsx @@ -22,7 +22,8 @@ import { cn } from '@/lib/utils' import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel' import { ThinkingStream, DEFAULT_THINKING_MESSAGES } from './thinking-stream' import { ApprovalCard, type ApprovalRequest } from '@/components/approval/approval-card' -import { RefreshCw, AlertCircle, CheckCircle2 } from 'lucide-react' +import { SlidePanel } from '@/components/ui/slide-panel' +import { RefreshCw, AlertCircle, CheckCircle2, AlertTriangle, Shield, XCircle, ChevronRight } from 'lucide-react' // ============================================================================= // Types @@ -102,9 +103,26 @@ export function OpenClawStateMachine({ const [error, setError] = useState(null) const [lastFetch, setLastFetch] = useState(null) + // Slide Panel 狀態 + const [selectedIndex, setSelectedIndex] = useState(null) + const selectedApproval = selectedIndex !== null ? pendingApprovals[selectedIndex] : null + // Timer refs for cleanup const pollTimerRef = useRef(null) + // 導航 + const handlePrevApproval = useCallback(() => { + if (selectedIndex !== null && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1) + } + }, [selectedIndex]) + + const handleNextApproval = useCallback(() => { + if (selectedIndex !== null && selectedIndex < pendingApprovals.length - 1) { + setSelectedIndex(selectedIndex + 1) + } + }, [selectedIndex, pendingApprovals.length]) + // ========================================================================== // API: Fetch Pending Approvals // ========================================================================== @@ -308,22 +326,124 @@ export function OpenClawStateMachine({ alertType={pendingApprovals.length > 0 ? 'POD_CRASH' : undefined} /> - {/* Pending Approvals List (REAL DATA) */} + {/* Pending Approvals List - 緊湊列表模式 */} {pendingApprovals.length > 0 && ( -
- {pendingApprovals.map((approval) => ( -
- handleApprove(approval.id)} - onReject={() => handleReject(approval.id)} - holdDuration={1000} - /> -
- ))} +
+ {/* 批次操作提示 */} +
+ + {pendingApprovals.length} 筆待簽核 + + + 點擊查看詳情 + +
+ + {/* 緊湊列表 */} + {pendingApprovals.map((approval, index) => { + const isCritical = approval.riskLevel === 'critical' + const isHigh = approval.riskLevel === 'high' + const title = approval.action.includes('|') + ? approval.action.split('|')[0].trim() + : approval.action + + return ( +
setSelectedIndex(index)} + className={cn( + 'group flex items-center gap-3 p-3 rounded-xl cursor-pointer', + 'bg-white border transition-all duration-200', + 'hover:shadow-md hover:-translate-y-0.5', + isCritical && 'border-l-4 border-l-status-critical border-status-critical/30 bg-status-critical/5', + isHigh && 'border-l-4 border-l-status-warning border-status-warning/30', + !isCritical && !isHigh && 'border-nothing-gray-200 hover:border-nothing-gray-300', + selectedIndex === index && 'ring-2 ring-claw-blue' + )} + > + {/* 風險指示器 */} +
+ {isCritical || isHigh ? ( + + ) : ( + + )} +
+ + {/* 標題 */} +
+
+ + {title} + +
+
+ + {approval.riskLevel} + + + {approval.currentSignatures}/{approval.requiredSignatures} + +
+
+ + {/* 箭頭 */} + +
+ ) + })}
)} + {/* Slide Panel - 詳情面板 */} + setSelectedIndex(null)} + title="審核詳情" + onPrev={handlePrevApproval} + onNext={handleNextApproval} + current={selectedIndex !== null ? selectedIndex + 1 : undefined} + total={pendingApprovals.length} + width="lg" + > + {selectedApproval && ( +
+ { + handleApprove(selectedApproval.id) + // 簽核後移到下一個或關閉 + if (selectedIndex !== null && selectedIndex < pendingApprovals.length - 1) { + setSelectedIndex(selectedIndex + 1) + } else { + setSelectedIndex(null) + } + }} + onReject={() => { + handleReject(selectedApproval.id) + setSelectedIndex(null) + }} + holdDuration={1000} + /> +
+ )} +
+ {/* Idle state - no pending approvals */} {machineState === 'idle' && pendingApprovals.length === 0 && !isLoading && (
diff --git a/apps/web/src/components/approval/approval-card.tsx b/apps/web/src/components/approval/approval-card.tsx index 3b0a3d515..f3302966f 100644 --- a/apps/web/src/components/approval/approval-card.tsx +++ b/apps/web/src/components/approval/approval-card.tsx @@ -25,7 +25,7 @@ import { } from '@/components/ui/glass-card' import { StatusOrb } from '@/components/ui/status-orb' import { cn } from '@/lib/utils' -import { AlertTriangle, CheckCircle2, XCircle, Clock, Shield, Zap } from 'lucide-react' +import { AlertTriangle, CheckCircle2, XCircle, Clock, Shield, Zap, ChevronDown } from 'lucide-react' // ============================================================================= // Types @@ -267,6 +267,9 @@ export function ApprovalCard({ const tBlast = useTranslations('blastRadius') const tDryRun = useTranslations('dryRun') + // UX 優化: 折疊狀態 (預設收合,減少垂直空間佔用) + const [isExpanded, setIsExpanded] = useState(false) + // 微交互狀態: 處理中 + 滑出動畫 const [isProcessing, setIsProcessing] = useState(false) const [isExiting, setIsExiting] = useState(false) diff --git a/apps/web/src/components/approval/compact-approval-item.tsx b/apps/web/src/components/approval/compact-approval-item.tsx new file mode 100644 index 000000000..26af6ea94 --- /dev/null +++ b/apps/web/src/components/approval/compact-approval-item.tsx @@ -0,0 +1,182 @@ +'use client' + +/** + * CompactApprovalItem - 緊湊型待簽核項目 + * ====================================== + * UX 優化:列表顯示 + 點擊展開彈窗 + * + * 特點: + * - 單行顯示:風險等級 + 標題 + 簽核進度 + * - 快速 Y/n 按鈕(不需長按) + * - 點擊標題 → 彈窗詳情 + */ + +import { useState, useCallback } from 'react' +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { AlertTriangle, CheckCircle2, ChevronRight, Shield, XCircle } from 'lucide-react' +import { Dialog } from '@/components/ui/dialog' +import { ApprovalCard, type ApprovalRequest } from './approval-card' + +interface CompactApprovalItemProps { + request: ApprovalRequest + onApprove: (id: string) => void + onReject: (id: string) => void + holdDuration?: number +} + +export function CompactApprovalItem({ + request, + onApprove, + onReject, + holdDuration = 1000, +}: CompactApprovalItemProps) { + const t = useTranslations('approval') + const tRisk = useTranslations('risk') + const [showDetail, setShowDetail] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + + const isCritical = request.riskLevel === 'critical' + const isHigh = request.riskLevel === 'high' + + // 快速拒絕(不需彈窗) + const handleQuickReject = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setIsProcessing(true) + onReject(request.id) + }, [onReject, request.id]) + + // 簽核(打開彈窗確認) + const handleApproveClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setShowDetail(true) + }, []) + + // 從彈窗內簽核 + const handleApproveFromModal = useCallback((id: string) => { + setIsProcessing(true) + setShowDetail(false) + onApprove(id) + }, [onApprove]) + + // 從彈窗內拒絕 + const handleRejectFromModal = useCallback((id: string) => { + setIsProcessing(true) + setShowDetail(false) + onReject(id) + }, [onReject]) + + // 解析標題(取 | 前的動作描述) + const title = request.action.includes('|') + ? request.action.split('|')[0].trim() + : request.action + + return ( + <> + {/* 緊湊列表項目 */} +
setShowDetail(true)} + > + {/* 風險等級指示器 */} +
+ {isCritical || isHigh ? ( + + ) : ( + + )} +
+ + {/* 標題與摘要 */} +
+
+ + {title} + + +
+
+ + {tRisk(request.riskLevel)} + + + {request.currentSignatures}/{request.requiredSignatures} + +
+
+ + {/* 快速操作按鈕 */} +
+ {/* 拒絕 */} + + + {/* 簽核(打開詳情) */} + +
+
+ + {/* 詳情彈窗 */} + setShowDetail(false)} + title={t('approvalDetail')} + className="max-w-xl" + > +
+ +
+
+ + ) +} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 000000000..24c0a63fd --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,79 @@ +'use client' + +/** + * Dialog - 專業級彈窗組件 + * ======================== + * Nothing.tech 設計語言,支援 ApprovalCard 詳情顯示 + */ + +import { useEffect, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { X } from 'lucide-react' + +interface DialogProps { + open: boolean + onClose: () => void + children: React.ReactNode + title?: string + className?: string +} + +export function Dialog({ open, onClose, children, title, className }: DialogProps) { + // ESC 關閉 + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, [onClose]) + + useEffect(() => { + if (open) { + document.addEventListener('keydown', handleKeyDown) + document.body.style.overflow = 'hidden' + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [open, handleKeyDown]) + + if (!open) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog Panel */} +
+ {/* Header */} + {title && ( +
+

+ {title} +

+ +
+ )} + + {/* Content */} +
+ {children} +
+
+
+ ) +} diff --git a/apps/web/src/components/ui/slide-panel.tsx b/apps/web/src/components/ui/slide-panel.tsx new file mode 100644 index 000000000..3579e168f --- /dev/null +++ b/apps/web/src/components/ui/slide-panel.tsx @@ -0,0 +1,149 @@ +'use client' + +/** + * SlidePanel - 專業級側邊滑入面板 + * ================================ + * 業界標準 AIOps UX: + * - PagerDuty / ServiceNow 風格 + * - 不遮蔽主內容,保持上下文 + * - 固定底部操作按鈕 + */ + +import { useEffect, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { X, ChevronLeft, ChevronRight } from 'lucide-react' + +interface SlidePanelProps { + open: boolean + onClose: () => void + children: React.ReactNode + title?: string + /** 上一個/下一個導航 */ + onPrev?: () => void + onNext?: () => void + /** 當前位置 (1-based) */ + current?: number + total?: number + /** 寬度 */ + width?: 'sm' | 'md' | 'lg' | 'xl' +} + +const widthMap = { + sm: 'w-80', + md: 'w-96', + lg: 'w-[480px]', + xl: 'w-[560px]', +} + +export function SlidePanel({ + open, + onClose, + children, + title, + onPrev, + onNext, + current, + total, + width = 'lg', +}: SlidePanelProps) { + // ESC 關閉 + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + // 方向鍵導航 + if (e.key === 'ArrowLeft' && onPrev) onPrev() + if (e.key === 'ArrowRight' && onNext) onNext() + }, [onClose, onPrev, onNext]) + + useEffect(() => { + if (open) { + document.addEventListener('keydown', handleKeyDown) + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, handleKeyDown]) + + return ( + <> + {/* Backdrop - 半透明,點擊關閉 */} +
+ + {/* Panel */} +
+ {/* Header - 固定 */} +
+
+ + {title && ( +

+ {title} +

+ )} +
+ + {/* 導航控制 */} + {(onPrev || onNext) && current && total && ( +
+ + {current} / {total} + +
+ + +
+
+ )} +
+ + {/* Content - 可滾動 */} +
+ {children} +
+
+ + ) +}