feat(web): Professional UX for approval workflow
Industry-standard AIOps UX patterns: - Compact approval list in right panel - SlidePanel for details (PagerDuty/ServiceNow style) - Keyboard navigation (←/→ for prev/next, ESC to close) - Quick access to approve/reject - Maintains context while reviewing details Fixes large empty space issue and endless scrolling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(null)
|
||||
const [lastFetch, setLastFetch] = useState<Date | null>(null)
|
||||
|
||||
// Slide Panel 狀態
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const selectedApproval = selectedIndex !== null ? pendingApprovals[selectedIndex] : null
|
||||
|
||||
// Timer refs for cleanup
|
||||
const pollTimerRef = useRef<NodeJS.Timeout | null>(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 && (
|
||||
<div className="space-y-3">
|
||||
{pendingApprovals.map((approval) => (
|
||||
<div key={approval.id} className="animate-slide-in-up">
|
||||
<ApprovalCard
|
||||
request={approval}
|
||||
onApprove={() => handleApprove(approval.id)}
|
||||
onReject={() => handleReject(approval.id)}
|
||||
holdDuration={1000}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
{/* 批次操作提示 */}
|
||||
<div className="flex items-center justify-between px-1 mb-2">
|
||||
<span className="text-[10px] font-mono text-nothing-gray-400">
|
||||
{pendingApprovals.length} 筆待簽核
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-nothing-gray-400">
|
||||
點擊查看詳情
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 緊湊列表 */}
|
||||
{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 (
|
||||
<div
|
||||
key={approval.id}
|
||||
onClick={() => 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'
|
||||
)}
|
||||
>
|
||||
{/* 風險指示器 */}
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
isCritical && 'bg-status-critical/10',
|
||||
isHigh && 'bg-status-warning/10',
|
||||
approval.riskLevel === 'medium' && 'bg-status-warning/10',
|
||||
approval.riskLevel === 'low' && 'bg-status-healthy/10'
|
||||
)}>
|
||||
{isCritical || isHigh ? (
|
||||
<AlertTriangle className={cn(
|
||||
'w-4 h-4',
|
||||
isCritical ? 'text-status-critical' : 'text-status-warning'
|
||||
)} />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 標題 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium text-nothing-gray-900 truncate">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={cn(
|
||||
'text-[10px] font-mono font-bold uppercase px-1.5 py-0.5 rounded',
|
||||
isCritical && 'bg-status-critical/10 text-status-critical',
|
||||
isHigh && 'bg-status-warning/10 text-status-warning',
|
||||
approval.riskLevel === 'medium' && 'bg-status-warning/10 text-status-warning',
|
||||
approval.riskLevel === 'low' && 'bg-status-healthy/10 text-status-healthy'
|
||||
)}>
|
||||
{approval.riskLevel}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-nothing-gray-400">
|
||||
{approval.currentSignatures}/{approval.requiredSignatures}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 箭頭 */}
|
||||
<ChevronRight className="w-4 h-4 text-nothing-gray-400 flex-shrink-0 group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide Panel - 詳情面板 */}
|
||||
<SlidePanel
|
||||
open={selectedIndex !== null}
|
||||
onClose={() => setSelectedIndex(null)}
|
||||
title="審核詳情"
|
||||
onPrev={handlePrevApproval}
|
||||
onNext={handleNextApproval}
|
||||
current={selectedIndex !== null ? selectedIndex + 1 : undefined}
|
||||
total={pendingApprovals.length}
|
||||
width="lg"
|
||||
>
|
||||
{selectedApproval && (
|
||||
<div className="p-4">
|
||||
<ApprovalCard
|
||||
request={selectedApproval}
|
||||
onApprove={() => {
|
||||
handleApprove(selectedApproval.id)
|
||||
// 簽核後移到下一個或關閉
|
||||
if (selectedIndex !== null && selectedIndex < pendingApprovals.length - 1) {
|
||||
setSelectedIndex(selectedIndex + 1)
|
||||
} else {
|
||||
setSelectedIndex(null)
|
||||
}
|
||||
}}
|
||||
onReject={() => {
|
||||
handleReject(selectedApproval.id)
|
||||
setSelectedIndex(null)
|
||||
}}
|
||||
holdDuration={1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
{/* Idle state - no pending approvals */}
|
||||
{machineState === 'idle' && pendingApprovals.length === 0 && !isLoading && (
|
||||
<div className="text-center py-6 border border-dashed border-nothing-gray-200 rounded-lg">
|
||||
|
||||
@@ -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)
|
||||
|
||||
182
apps/web/src/components/approval/compact-approval-item.tsx
Normal file
182
apps/web/src/components/approval/compact-approval-item.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
{/* 緊湊列表項目 */}
|
||||
<div
|
||||
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',
|
||||
isHigh && 'border-l-4 border-l-status-warning border-status-warning/30',
|
||||
!isCritical && !isHigh && 'border-nothing-gray-200 hover:border-nothing-gray-300',
|
||||
isProcessing && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
onClick={() => setShowDetail(true)}
|
||||
>
|
||||
{/* 風險等級指示器 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
isCritical && 'bg-status-critical/10',
|
||||
isHigh && 'bg-status-warning/10',
|
||||
request.riskLevel === 'medium' && 'bg-status-warning/10',
|
||||
request.riskLevel === 'low' && 'bg-status-healthy/10'
|
||||
)}
|
||||
>
|
||||
{isCritical || isHigh ? (
|
||||
<AlertTriangle className={cn(
|
||||
'w-4 h-4',
|
||||
isCritical ? 'text-status-critical' : 'text-status-warning'
|
||||
)} />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 標題與摘要 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium text-nothing-gray-900 truncate">
|
||||
{title}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-nothing-gray-400 flex-shrink-0 group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={cn(
|
||||
'text-[10px] font-mono font-bold uppercase px-1.5 py-0.5 rounded',
|
||||
isCritical && 'bg-status-critical/10 text-status-critical',
|
||||
isHigh && 'bg-status-warning/10 text-status-warning',
|
||||
request.riskLevel === 'medium' && 'bg-status-warning/10 text-status-warning',
|
||||
request.riskLevel === 'low' && 'bg-status-healthy/10 text-status-healthy'
|
||||
)}>
|
||||
{tRisk(request.riskLevel)}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-nothing-gray-400">
|
||||
{request.currentSignatures}/{request.requiredSignatures}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快速操作按鈕 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* 拒絕 */}
|
||||
<button
|
||||
onClick={handleQuickReject}
|
||||
className={cn(
|
||||
'p-2 rounded-lg border-2 border-dashed border-nothing-gray-300',
|
||||
'text-nothing-gray-500 hover:border-status-critical hover:text-status-critical',
|
||||
'hover:bg-status-critical/5 transition-all'
|
||||
)}
|
||||
title={t('reject')}
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 簽核(打開詳情) */}
|
||||
<button
|
||||
onClick={handleApproveClick}
|
||||
className={cn(
|
||||
'p-2 rounded-lg border-2 border-dashed',
|
||||
isCritical ? 'border-status-critical text-status-critical hover:bg-status-critical/5' :
|
||||
'border-claw-blue text-claw-blue hover:bg-claw-blue/5',
|
||||
'transition-all'
|
||||
)}
|
||||
title={t('holdToSign')}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 詳情彈窗 */}
|
||||
<Dialog
|
||||
open={showDetail}
|
||||
onClose={() => setShowDetail(false)}
|
||||
title={t('approvalDetail')}
|
||||
className="max-w-xl"
|
||||
>
|
||||
<div className="p-4">
|
||||
<ApprovalCard
|
||||
request={request}
|
||||
onApprove={handleApproveFromModal}
|
||||
onReject={handleRejectFromModal}
|
||||
holdDuration={holdDuration}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
79
apps/web/src/components/ui/dialog.tsx
Normal file
79
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 w-full max-w-lg max-h-[85vh] overflow-y-auto',
|
||||
'bg-white rounded-2xl shadow-2xl',
|
||||
'animate-in fade-in zoom-in-95 duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-white border-b border-nothing-gray-100">
|
||||
<h2 className="font-heading text-lg font-bold text-nothing-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-nothing-gray-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-nothing-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn(!title && 'pt-6')}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
149
apps/web/src/components/ui/slide-panel.tsx
Normal file
149
apps/web/src/components/ui/slide-panel.tsx
Normal file
@@ -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 - 半透明,點擊關閉 */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-black/20 transition-opacity duration-300',
|
||||
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 z-50 h-full bg-white shadow-2xl',
|
||||
'flex flex-col',
|
||||
'transition-transform duration-300 ease-out',
|
||||
widthMap[width],
|
||||
open ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
>
|
||||
{/* Header - 固定 */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 border-b border-nothing-gray-100 bg-nothing-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-nothing-gray-200 transition-colors"
|
||||
title="關閉 (ESC)"
|
||||
>
|
||||
<X className="w-5 h-5 text-nothing-gray-500" />
|
||||
</button>
|
||||
{title && (
|
||||
<h2 className="font-heading text-base font-bold text-nothing-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 導航控制 */}
|
||||
{(onPrev || onNext) && current && total && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-nothing-gray-400">
|
||||
{current} / {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={current <= 1}
|
||||
className={cn(
|
||||
'p-1.5 rounded-lg transition-colors',
|
||||
current <= 1
|
||||
? 'text-nothing-gray-300 cursor-not-allowed'
|
||||
: 'text-nothing-gray-500 hover:bg-nothing-gray-200'
|
||||
)}
|
||||
title="上一個 (←)"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={current >= total}
|
||||
className={cn(
|
||||
'p-1.5 rounded-lg transition-colors',
|
||||
current >= total
|
||||
? 'text-nothing-gray-300 cursor-not-allowed'
|
||||
: 'text-nothing-gray-500 hover:bg-nothing-gray-200'
|
||||
)}
|
||||
title="下一個 (→)"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content - 可滾動 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user