feat(web): Phase 6.5c implement [Y/n] execution wiring

DualStateIncidentCard:
- Add proposalId prop for approval actions
- Add onApprovalChange callback for status updates
- Implement handleApprove() calling POST /api/v1/approvals/{id}/sign
- Implement handleReject() calling POST /api/v1/approvals/{id}/reject
- Add ButtonState management (idle/loading/approved/rejected/error)
- Loading spinner during API call
- Success state: green "已授權" / red "已拒絕"
- Error state: orange "錯誤" with auto-recovery

API Client:
- Fix endpoint mismatch: rename approveApproval to signApproval
- Use correct endpoint /sign instead of /approve
- Add signer parameter for multi-sig support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-23 12:37:56 +08:00
parent a769738499
commit 28fa8e6af4
2 changed files with 133 additions and 11 deletions

View File

@@ -14,10 +14,18 @@
* - normal: 淺灰邊框,靜態
* - alert: 紅色邊框,脈衝雷達動畫
*
* Phase 6.5c: [Y/n] 執行神經實裝
* - Y: 呼叫 signApproval API
* - n: 呼叫 rejectApproval API
* - Loading state + Success/Error feedback
*
* 統帥鐵律: 禁止假數據!
*/
import React from 'react'
import React, { useState, useCallback } from 'react'
import { apiClient } from '@/lib/api-client'
type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error'
export interface DualStateIncidentCardProps {
id: string
@@ -26,6 +34,10 @@ export interface DualStateIncidentCardProps {
tier?: 1 | 2 | 3
message: string
timestamp: string
/** Proposal ID for approval actions (required for tier > 1) */
proposalId?: string
/** Callback when approval status changes */
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
}
export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
@@ -35,8 +47,116 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
tier,
message,
timestamp,
proposalId,
onApprovalChange,
}) => {
const isAlert = status === 'alert'
const [buttonState, setButtonState] = useState<ButtonState>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
/**
* 處理簽核 (Y 按鈕)
* 呼叫 POST /api/v1/approvals/{id}/sign
*/
const handleApprove = useCallback(async () => {
if (!proposalId || buttonState === 'loading') return
setButtonState('loading')
setErrorMessage(null)
try {
const result = await apiClient.signApproval(proposalId, 'commander')
if (result.status === 'approved' || result.status === 'APPROVED') {
setButtonState('approved')
onApprovalChange?.(proposalId, 'approved')
} else {
// Multi-sig: 還需要更多簽核
setButtonState('idle')
}
} catch (error) {
console.error('[DualStateIncidentCard] Approve failed:', error)
setButtonState('error')
setErrorMessage(error instanceof Error ? error.message : 'Unknown error')
// 3 秒後恢復
setTimeout(() => setButtonState('idle'), 3000)
}
}, [proposalId, buttonState, onApprovalChange])
/**
* 處理拒絕 (n 按鈕)
* 呼叫 POST /api/v1/approvals/{id}/reject
*/
const handleReject = useCallback(async () => {
if (!proposalId || buttonState === 'loading') return
setButtonState('loading')
setErrorMessage(null)
try {
await apiClient.rejectApproval(proposalId, 'commander')
setButtonState('rejected')
onApprovalChange?.(proposalId, 'rejected')
} catch (error) {
console.error('[DualStateIncidentCard] Reject failed:', error)
setButtonState('error')
setErrorMessage(error instanceof Error ? error.message : 'Unknown error')
setTimeout(() => setButtonState('idle'), 3000)
}
}, [proposalId, buttonState, onApprovalChange])
/**
* 渲染決策按鈕區塊
*/
const renderActionButton = () => {
switch (buttonState) {
case 'loading':
return (
<span className="px-3 py-1 bg-neutral-700 text-white text-xs flex items-center gap-2">
<span className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</span>
)
case 'approved':
return (
<span className="px-3 py-1 bg-green-600 text-white text-xs">
[ ]
</span>
)
case 'rejected':
return (
<span className="px-3 py-1 bg-red-600 text-white text-xs">
[ ]
</span>
)
case 'error':
return (
<span className="px-3 py-1 bg-orange-500 text-white text-xs" title={errorMessage || ''}>
[ ]
</span>
)
default:
return (
<div className="flex gap-1">
<button
onClick={handleApprove}
disabled={!proposalId}
className="px-2 py-1 bg-neutral-900 text-white text-xs hover:bg-green-700 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Y
</button>
<span className="px-1 py-1 text-neutral-400 text-xs">/</span>
<button
onClick={handleReject}
disabled={!proposalId}
className="px-2 py-1 bg-neutral-900 text-white text-xs hover:bg-red-700 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
n
</button>
</div>
)
}
}
return (
<div
@@ -77,17 +197,13 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
</div>
<div className="mt-1 text-xs text-neutral-400">{timestamp}</div>
{/* 大腦決策層 (Proposal UI) */}
{/* 大腦決策層 (Proposal UI) - Phase 6.5c 執行神經實裝 */}
{isAlert && tier && (
<div className="mt-4 pt-3 border-t-[0.5px] border-red-200 flex justify-between items-center">
<span className="text-xs text-neutral-500">
{tier === 1 ? '>_ AI 執行中 (Tier 1)' : `>_ 等待統帥親核 (Tier ${tier})`}
</span>
{tier > 1 && (
<button className="px-3 py-1 bg-neutral-900 text-white text-xs hover:bg-black transition-colors cursor-pointer">
[ Y / n ]
</button>
)}
{tier > 1 && renderActionButton()}
</div>
)}
</div>

View File

@@ -114,13 +114,19 @@ export const apiClient = {
}>(res)
},
async approveApproval(approvalId: string, reason?: string) {
const res = await fetch(`${API_BASE_URL}/approvals/${approvalId}/approve`, {
async signApproval(approvalId: string, signer: string = 'commander', reason?: string) {
const res = await fetch(`${API_BASE_URL}/approvals/${approvalId}/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
body: JSON.stringify({ signer, reason }),
})
return handleResponse<{ id: string; status: string }>(res)
return handleResponse<{
approval_id: string
status: string
current_signatures: number
required_signatures: number
message: string
}>(res)
},
async rejectApproval(approvalId: string, reason?: string) {