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:
OG T
2026-03-25 10:31:35 +08:00
parent 170102a4ee
commit b13b063282
11 changed files with 1341 additions and 9 deletions

View File

@@ -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)
# =============================================================================

View File

@@ -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 的處理結果")

View File

@@ -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",

View File

@@ -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": "低風險",

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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
// =============================================================================

View File

@@ -4,3 +4,4 @@ export * from './useSSE'
export * from './useApprovalSSE'
export * from './useIncidents'
export * from './useGlobalPulseMetrics'
export * from './useKeyboardShortcuts'

View File

@@ -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<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,
}
}

View File

@@ -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 |