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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user