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>
243 lines
9.2 KiB
TypeScript
243 lines
9.2 KiB
TypeScript
'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>
|
||
)
|
||
}
|