feat(web): Phase 11 對話式 AI UI/UX (#47-59)
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>
This commit is contained in:
186
apps/web/src/components/approval/approval-thread-item.tsx
Normal file
186
apps/web/src/components/approval/approval-thread-item.tsx
Normal file
@@ -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<typeof useTranslations>): 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 (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'relative w-full text-left p-3 rounded-xl transition-all duration-200',
|
||||
'border border-transparent',
|
||||
'hover:bg-nothing-gray-50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-claw-blue/50',
|
||||
isSelected && [
|
||||
'bg-claw-blue/5',
|
||||
'border-claw-blue/30',
|
||||
'shadow-sm',
|
||||
],
|
||||
!isSelected && 'hover:border-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
{/* 頂部: 風險指示器 + 時間 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-mono',
|
||||
riskConfig.bgColor,
|
||||
riskConfig.color
|
||||
)}>
|
||||
<RiskIcon className="w-3 h-3" />
|
||||
<span className="uppercase">{approval.riskLevel}</span>
|
||||
</div>
|
||||
<span className="text-xs text-nothing-gray-400 font-mono">
|
||||
{relativeTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 中間: 動作描述 */}
|
||||
<div className="mb-2">
|
||||
<h4 className={cn(
|
||||
'font-heading text-sm font-medium truncate',
|
||||
isSelected ? 'text-claw-blue' : 'text-nothing-gray-900'
|
||||
)}>
|
||||
{approval.description}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* 底部: 動作類型 + 狀態指示 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-nothing-gray-500 font-mono truncate max-w-[70%]">
|
||||
{actionSummary}
|
||||
</span>
|
||||
<StatusOrb
|
||||
status={riskConfig.orbStatus}
|
||||
size="sm"
|
||||
pulse={approval.riskLevel === 'critical'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 選中指示線 */}
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-claw-blue rounded-r-full" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
305
apps/web/src/components/approval/batch-mode-selector.tsx
Normal file
305
apps/web/src/components/approval/batch-mode-selector.tsx
Normal file
@@ -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<BatchMode>('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 (
|
||||
<div className={cn('p-4 bg-white rounded-xl border border-nothing-gray-200', className)}>
|
||||
{/* 統計概覽 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-heading text-sm font-bold text-nothing-gray-900">
|
||||
{tBatch('title')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-nothing-gray-400">{stats.total} {t('pendingApprovals')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 風險等級分佈 */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-nothing-gray-50">
|
||||
<StatusOrb status="healthy" size="sm" />
|
||||
<span className="text-xs font-mono text-nothing-gray-600">
|
||||
LOW: {stats.low}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-claw-blue/5">
|
||||
<StatusOrb status="healthy" size="sm" />
|
||||
<span className="text-xs font-mono text-claw-blue">
|
||||
MED: {stats.medium}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-status-warning/5">
|
||||
<StatusOrb status="warning" size="sm" />
|
||||
<span className="text-xs font-mono text-status-warning">
|
||||
HIGH: {stats.high}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-status-critical/5">
|
||||
<StatusOrb status="critical" size="sm" pulse />
|
||||
<span className="text-xs font-mono text-status-critical">
|
||||
CRIT: {stats.critical}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式選擇按鈕 */}
|
||||
<div className="space-y-2">
|
||||
{/* 全部接受 (批次) */}
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={stats.canBulkApprove === 0 || isProcessing}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-status-healthy/5 hover:border-status-healthy/30',
|
||||
'focus:outline-none focus:ring-2 focus:ring-status-healthy/50',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
mode === 'bulk_approve' && 'bg-status-healthy/10 border-status-healthy/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-4 h-4 text-status-healthy animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-nothing-gray-900">
|
||||
{tBatch('bulkApprove')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-nothing-gray-400">
|
||||
{stats.canBulkApprove} {tBatch('eligible')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 逐一審核 */}
|
||||
<button
|
||||
onClick={() => setMode('sequential')}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-claw-blue/50',
|
||||
mode === 'sequential' && 'bg-claw-blue/5 border-claw-blue/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListFilter className="w-4 h-4 text-claw-blue" />
|
||||
<span className="text-sm font-medium text-nothing-gray-900">
|
||||
{tBatch('sequential')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-nothing-gray-400">
|
||||
{stats.total} {tBatch('items')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 僅顯示 CRITICAL */}
|
||||
<button
|
||||
onClick={() => setMode('filter_critical')}
|
||||
disabled={stats.critical === 0}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-status-critical/5 hover:border-status-critical/30',
|
||||
'focus:outline-none focus:ring-2 focus:ring-status-critical/50',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
mode === 'filter_critical' && 'bg-status-critical/10 border-status-critical/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-status-critical" />
|
||||
<span className="text-sm font-medium text-nothing-gray-900">
|
||||
{tBatch('criticalOnly')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-status-critical">
|
||||
{stats.critical} {tBatch('items')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<div className="mt-4 flex items-start gap-2 p-2 rounded-lg bg-status-warning/5 border border-status-warning/20">
|
||||
<Shield className="w-4 h-4 text-status-warning flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-status-warning">
|
||||
{tBatch('securityNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
345
apps/web/src/components/approval/conversational-view.tsx
Normal file
345
apps/web/src/components/approval/conversational-view.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className={cn('flex h-full bg-nothing-gray-50', className)}>
|
||||
{/* 左側: 對話列表 */}
|
||||
<div className="w-80 flex-shrink-0 border-r border-nothing-gray-200 bg-white flex flex-col">
|
||||
{/* 列表 Header */}
|
||||
<div className="flex-shrink-0 p-4 border-b border-nothing-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-claw-blue" />
|
||||
<h2 className="font-heading text-base font-bold text-nothing-gray-900">
|
||||
{t('pendingApprovals')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* SSE 連線狀態 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-mono',
|
||||
isConnected
|
||||
? 'bg-status-healthy/10 text-status-healthy'
|
||||
: 'bg-status-warning/10 text-status-warning'
|
||||
)}
|
||||
title={`SSE: ${sseStatus}`}
|
||||
>
|
||||
{isConnected ? (
|
||||
<Wifi className="w-3 h-3" />
|
||||
) : (
|
||||
<WifiOff className="w-3 h-3" />
|
||||
)}
|
||||
<span>{approvals.length}</span>
|
||||
</div>
|
||||
{/* 快捷鍵提示 */}
|
||||
<button
|
||||
onClick={() => setShowShortcuts((s) => !s)}
|
||||
className="p-1.5 rounded-lg hover:bg-nothing-gray-100 transition-colors"
|
||||
title="快捷鍵 (?)"
|
||||
>
|
||||
<Keyboard className="w-4 h-4 text-nothing-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 導航提示 */}
|
||||
{approvals.length > 1 && (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-nothing-gray-400">
|
||||
<span className="font-mono">
|
||||
{selectedIndex + 1} / {approvals.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={selectedIndex <= 0}
|
||||
className={cn(
|
||||
'p-1 rounded transition-colors',
|
||||
selectedIndex <= 0
|
||||
? 'text-nothing-gray-300 cursor-not-allowed'
|
||||
: 'hover:bg-nothing-gray-100 text-nothing-gray-500'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={selectedIndex >= approvals.length - 1}
|
||||
className={cn(
|
||||
'p-1 rounded transition-colors',
|
||||
selectedIndex >= approvals.length - 1
|
||||
? 'text-nothing-gray-300 cursor-not-allowed'
|
||||
: 'hover:bg-nothing-gray-100 text-nothing-gray-500'
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 列表內容 */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{approvals.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-nothing-gray-400">
|
||||
<StatusOrb status="healthy" size="lg" />
|
||||
<p className="mt-4 text-sm font-mono">{t('noPendingApprovals')}</p>
|
||||
</div>
|
||||
) : (
|
||||
approvals.map((approval) => (
|
||||
<ApprovalThreadItem
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
isSelected={approval.id === selectedId}
|
||||
onSelect={() => setSelectedId(approval.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右側: 詳情面板 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* Y 鍵長按進度指示器 */}
|
||||
{isYKeyHolding && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-nothing-gray-200 z-10">
|
||||
<div
|
||||
className="h-full bg-status-healthy transition-all duration-100"
|
||||
style={{ width: `${yKeyProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedApproval ? (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<ApprovalCard
|
||||
request={selectedApproval}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
holdDuration={2000}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-nothing-gray-400">
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm font-mono">{t('selectApproval')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 快捷鍵說明 Modal */}
|
||||
{showShortcuts && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={() => setShowShortcuts(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-heading text-lg font-bold text-nothing-gray-900 mb-4">
|
||||
{tCommon('keyboardShortcuts')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<ShortcutRow keys={['Y']} label={t('holdYToApprove')} highlight />
|
||||
<ShortcutRow keys={['N']} label={t('pressNToReject')} />
|
||||
<ShortcutRow keys={['↑', '←']} label={t('previousApproval')} />
|
||||
<ShortcutRow keys={['↓', '→']} label={t('nextApproval')} />
|
||||
<ShortcutRow keys={['?']} label={tCommon('showShortcuts')} />
|
||||
<ShortcutRow keys={['Esc']} label={tCommon('close')} />
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-nothing-gray-400 font-mono">
|
||||
{t('holdToApproveHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
function ShortcutRow({
|
||||
keys,
|
||||
label,
|
||||
highlight,
|
||||
}: {
|
||||
keys: string[]
|
||||
label: string
|
||||
highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded text-xs font-mono border',
|
||||
highlight
|
||||
? 'bg-status-healthy/10 text-status-healthy border-status-healthy/30'
|
||||
: 'bg-nothing-gray-100 text-nothing-gray-700 border-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-sm',
|
||||
highlight ? 'text-status-healthy font-medium' : 'text-nothing-gray-600'
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user