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:
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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 的處理結果")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "低風險",
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './useSSE'
|
||||
export * from './useApprovalSSE'
|
||||
export * from './useIncidents'
|
||||
export * from './useGlobalPulseMetrics'
|
||||
export * from './useKeyboardShortcuts'
|
||||
|
||||
208
apps/web/src/hooks/useKeyboardShortcuts.ts
Normal file
208
apps/web/src/hooks/useKeyboardShortcuts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user