Files
awoooi/apps/web/src/components/neural-command/NeuralApprovalPanel.tsx
OG T 246587a401 fix(web): Sprint F 前端打假行動 — 29處假數據全面清除 (首席架構師 98/100)
P0: Neural Command 三個子組件移除所有 MOCK 常數,接上真實 API props
- NeuralLiveCenter: 假歷史/假KPI/假雷達 → 從 stats/history/incidents 即時計算
- NeuralStats: MOCK_HISTORY/SCHEME_STATS/PLAYBOOK_RANKINGS → useMemo 聚合
- NeuralApprovalPanel: MOCK_PENDING → 真實 /api/v1/approvals 簽核操作

P1: 10+處假用戶身份 (demo-user/user-001/War Room User) → CURRENT_USER 常數統一
P2: 刪除 6 個 Demo 匯出 (GlobalPulseChartDemo/MOCK_APPROVAL/DEMO_DECISION_CHAIN)
P3: /demo 頁面加 NEXT_PUBLIC_ENABLE_DEMO 環境變數保護
i18n: 新增 22 個翻譯鍵 (zh-TW + en)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:53:52 +08:00

243 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* NeuralApprovalPanel - 核鑰授權面板
* =====================================
* 顯示真實待審核的操作,整合 NuclearKeyButton 長按確認
*
* 2026-04-07 Claude Code: Sprint F 打假行動 — 移除 MOCK_PENDING
* 統帥鐵律: 接真實 /api/v1/approvals/pending無資料顯示 EmptyState
*/
import { useState, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { AlertTriangle, CheckCircle2, XCircle, Inbox, Loader2 } from 'lucide-react'
import { NuclearKeyButton } from '@/components/genui/NuclearKeyButton'
import type { PendingApprovalItem } from './types'
import { CURRENT_USER } from '@/lib/constants'
interface Props {
approvals: PendingApprovalItem[]
onRefresh: () => void
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
export function NeuralApprovalPanel({ approvals, onRefresh }: Props) {
const t = useTranslations('neuralCommand')
const [processingId, setProcessingId] = useState<string | null>(null)
const [result, setResult] = useState<{ id: string; action: 'approved' | 'rejected' } | null>(null)
const [error, setError] = useState<string | null>(null)
const handleApprove = useCallback(async (approvalId: string) => {
setProcessingId(approvalId)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/approvals/${approvalId}/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signer_id: CURRENT_USER.id,
signer_name: CURRENT_USER.name,
comment: 'Approved via Neural Command Center',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setResult({ id: approvalId, action: 'approved' })
onRefresh()
} catch (err) {
setError(`${t('approvalError')}: ${err}`)
} finally {
setProcessingId(null)
}
}, [onRefresh, t])
const handleReject = useCallback(async (approvalId: string) => {
setProcessingId(approvalId)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/approvals/${approvalId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signer_id: CURRENT_USER.id,
signer_name: CURRENT_USER.name,
reason: 'Rejected via Neural Command Center',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setResult({ id: approvalId, action: 'rejected' })
onRefresh()
} catch (err) {
setError(`${t('approvalError')}: ${err}`)
} finally {
setProcessingId(null)
}
}, [onRefresh, t])
// 剛處理完某個審批的結果回饋
if (result) {
const isApproved = result.action === 'approved'
return (
<div className="p-6 flex items-center justify-center h-64">
<div className="text-center">
{isApproved
? <CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
: <XCircle className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
}
<p className="text-base font-semibold">
{isApproved ? t('approvalGranted') : t('approvalRejected')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{isApproved ? t('approvalGrantedDesc') : t('approvalRejectedDesc')}
</p>
<button
onClick={() => setResult(null)}
className="mt-4 px-4 py-2 rounded-lg border border-border text-sm hover:bg-muted transition-colors"
>
{t('backToList')}
</button>
</div>
</div>
)
}
// 無待審批
if (approvals.length === 0) {
return (
<div className="p-6 flex items-center justify-center h-64">
<div className="text-center text-muted-foreground">
<Inbox className="w-12 h-12 mx-auto mb-3 opacity-40" />
<p className="text-base font-semibold">{t('noApprovals')}</p>
<p className="text-sm mt-1">{t('noApprovalsDesc')}</p>
</div>
</div>
)
}
// 顯示第一個待審批 (最緊急的)
const approval = approvals[0]
const riskConfig: Record<string, { level: string; blocks: number; total: number }> = {
low: { level: 'LOW', blocks: 1, total: 4 },
medium: { level: 'MEDIUM', blocks: 2, total: 4 },
high: { level: 'HIGH', blocks: 3, total: 4 },
critical: { level: 'CRITICAL', blocks: 4, total: 4 },
}
const risk = riskConfig[approval.risk_level] ?? riskConfig['medium']
return (
<div className="p-6">
<div className="max-w-xl mx-auto space-y-5">
{/* 待審核數量指示 */}
{approvals.length > 1 && (
<div className="text-xs text-muted-foreground text-center">
{t('approvalQueueCount', { count: approvals.length })}
</div>
)}
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-orange-500/10 border border-orange-500/25 flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-orange-500" />
</div>
<div>
<h2 className="text-base font-bold">{t('approvalTitle')}</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{approval.action} · {approval.id}
</p>
</div>
</div>
{/* 描述 */}
<div className="rounded-xl border border-border bg-muted/30 p-4">
<p className="text-xs text-muted-foreground leading-relaxed">{approval.description}</p>
</div>
{/* Blast Radius */}
{approval.blast_radius && (
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-3">
{t('blastRadius')}
</p>
{[
{ label: t('affectedPods'), value: String(approval.blast_radius.affected_pods) },
{ label: t('estimatedDowntime'), value: approval.blast_radius.estimated_downtime },
{ label: t('relatedServices'), value: approval.blast_radius.related_services.join(', ') },
{ label: t('dataImpact'), value: approval.blast_radius.data_impact },
].map(({ label, value }) => (
<div key={label} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-28 flex-shrink-0">{label}</span>
<span className="text-xs font-mono bg-background border border-border rounded px-2 py-0.5 flex-1 min-w-0">
{value}
</span>
</div>
))}
</div>
)}
{/* Dry-run checks */}
{approval.dry_run_checks && approval.dry_run_checks.length > 0 && (
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-3">
{t('dryRunChecks')}
</p>
{approval.dry_run_checks.map((check, i) => (
<div key={i} className="flex items-center gap-2">
{check.passed
? <CheckCircle2 className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />
: <XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
}
<span className="text-xs">{check.name}</span>
{check.message && <span className="text-[10px] text-muted-foreground ml-auto">{check.message}</span>}
</div>
))}
</div>
)}
{/* Risk meter */}
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-orange-500/25 bg-orange-500/5">
<span className="text-sm font-semibold">{t('riskLevel')}</span>
<div className="flex gap-1">
{Array.from({ length: risk.total }).map((_, i) => (
<div key={i} className={cn(
'w-5 h-2 rounded-sm',
i < risk.blocks ? 'bg-orange-500' : 'bg-muted',
)} />
))}
</div>
<span className="text-sm font-semibold text-orange-500">{risk.level}</span>
</div>
{/* Error display */}
{error && (
<div className="p-3 bg-status-critical/10 border border-status-critical/30 rounded-lg">
<p className="text-sm text-status-critical font-body">{error}</p>
</div>
)}
{/* Nuclear confirm */}
<NuclearKeyButton
label={processingId ? t('processing') : t('confirmExec')}
onConfirm={() => handleApprove(approval.id)}
riskLevel={approval.risk_level === 'critical' ? 'critical' : 'medium'}
showShortcut
/>
{/* Reject */}
<button
onClick={() => handleReject(approval.id)}
disabled={!!processingId}
className="w-full py-3 rounded-xl border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{processingId
? <Loader2 className="w-4 h-4 animate-spin mx-auto" />
: t('rejectApproval')
}
</button>
</div>
</div>
)
}