45 個 component + 6 個 page 統一從舊 font-mono 遷移到 font-body (DM Mono),確保設計系統一致性。 font-body = DM Mono (等寬),視覺效果相同但走新設計 token。 保留: font-heading (Syne)、font-dot-matrix (VT323/DSEG7) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
/**
|
|
* ApprovalCard - 核鑰授權卡片
|
|
* ============================
|
|
* Phase 19.5 - Nuclear Key UX 整合
|
|
*
|
|
* 高風險操作需要儀式感授權:
|
|
* - 長按確認 (NuclearKeyButton)
|
|
* - 風險等級視覺化
|
|
* - 支援 Y 鍵快捷鍵
|
|
*
|
|
* @see ADR-032 GenUI Dynamic Rendering
|
|
* @see AWOOOI_AGENTIC_WORKSPACE_ROADMAP.md - Nuclear Key UX
|
|
* @author Claude Code (首席架構師)
|
|
* @version 2.0.0 - Nuclear Key 整合
|
|
* @date 2026-03-28 (台北時間)
|
|
*/
|
|
|
|
import React, { useState, useCallback } from 'react'
|
|
import { ShieldAlert, Terminal, Clock, User } from 'lucide-react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { NuclearKeyButton } from './NuclearKeyButton'
|
|
|
|
interface ApprovalCardProps {
|
|
data: {
|
|
approvalId: string
|
|
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
|
kubectl: string
|
|
requestedBy?: string
|
|
requestedAt?: string
|
|
description?: string
|
|
}
|
|
}
|
|
|
|
/** 風險等級映射 (API → NuclearKeyButton) */
|
|
const mapRiskLevel = (level: string): 'low' | 'medium' | 'high' | 'critical' => {
|
|
const mapping: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
|
LOW: 'low',
|
|
MEDIUM: 'medium',
|
|
HIGH: 'high',
|
|
CRITICAL: 'critical',
|
|
}
|
|
return mapping[level] ?? 'medium'
|
|
}
|
|
|
|
export const ApprovalCard: React.FC<ApprovalCardProps> = ({ data }) => {
|
|
const t = useTranslations('nuclearKey')
|
|
const tApproval = useTranslations('approval')
|
|
const [isExecuting, setIsExecuting] = useState(false)
|
|
const [executionResult, setExecutionResult] = useState<'success' | 'error' | null>(null)
|
|
|
|
const isCritical = data.riskLevel === 'CRITICAL' || data.riskLevel === 'HIGH'
|
|
const nuclearRiskLevel = mapRiskLevel(data.riskLevel)
|
|
|
|
const handleAuthorize = useCallback(async () => {
|
|
setIsExecuting(true)
|
|
setExecutionResult(null)
|
|
|
|
try {
|
|
// 實際呼叫審批 API
|
|
const response = await fetch(`/api/v1/approvals/${data.approvalId}/execute`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
|
|
if (response.ok) {
|
|
setExecutionResult('success')
|
|
} else {
|
|
setExecutionResult('error')
|
|
}
|
|
} catch {
|
|
setExecutionResult('error')
|
|
} finally {
|
|
setIsExecuting(false)
|
|
}
|
|
}, [data.approvalId])
|
|
|
|
return (
|
|
<div className="w-full max-w-2xl bg-white border-2 border-nothing-black shadow-lg rounded-sm overflow-hidden mt-3 sm:mt-4 mb-2">
|
|
{/* Header - Responsive layout */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b-2 border-nothing-black bg-nothing-gray-50 p-2 sm:p-3 gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldAlert className={`${isCritical ? 'text-red-500' : 'text-blue-500'} shrink-0`} size={18} />
|
|
<span className="font-['VT323'] text-lg sm:text-xl font-bold uppercase tracking-wider text-nothing-black truncate">
|
|
<span className="hidden sm:inline">Approval Required // </span>{data.approvalId}
|
|
</span>
|
|
</div>
|
|
<div className={`px-2 py-0.5 sm:py-1 text-xs font-body font-bold border-2 self-start sm:self-auto shrink-0 ${
|
|
isCritical ? 'border-red-500 text-red-500' : 'border-blue-500 text-blue-500'
|
|
}`}>
|
|
{data.riskLevel}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 sm:p-4 space-y-3 sm:space-y-4">
|
|
{/* Metadata */}
|
|
{(data.requestedBy || data.requestedAt) && (
|
|
<div className="flex flex-wrap gap-2 sm:gap-4 text-xs font-body text-gray-500">
|
|
{data.requestedBy && (
|
|
<div className="flex items-center gap-1">
|
|
<User size={12} />
|
|
<span>{data.requestedBy}</span>
|
|
</div>
|
|
)}
|
|
{data.requestedAt && (
|
|
<div className="flex items-center gap-1">
|
|
<Clock size={12} />
|
|
<span>{data.requestedAt}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
{data.description && (
|
|
<div>
|
|
<Label>Description</Label>
|
|
<p className="mt-1 text-xs sm:text-sm text-gray-700">{data.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Target Action */}
|
|
<div>
|
|
<Label>Target Action</Label>
|
|
<div className="mt-1 bg-nothing-gray-100 p-2 sm:p-3 rounded-sm border border-gray-200 shadow-inner flex items-start gap-2">
|
|
<Terminal size={14} className="text-gray-400 mt-0.5 flex-shrink-0 sm:w-4 sm:h-4" />
|
|
<code className="font-['VT323'] text-lg sm:text-xl text-[#4A90D9] break-all">
|
|
{data.kubectl}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Nuclear Key Authorization */}
|
|
<div className="pt-1 sm:pt-2">
|
|
{executionResult === 'success' ? (
|
|
<div className="w-full py-3 sm:py-4 bg-green-50 text-green-700 border-2 border-green-500 font-body font-bold text-xs sm:text-sm uppercase flex justify-center items-center gap-2 rounded-sm">
|
|
<span className="text-lg sm:text-xl">✓</span>
|
|
<span className="hidden sm:inline">{t('executionAuthorized')}</span>
|
|
<span className="sm:hidden">{t('authorized')}</span>
|
|
</div>
|
|
) : executionResult === 'error' ? (
|
|
<div className="w-full py-3 sm:py-4 bg-red-50 text-red-700 border-2 border-red-500 font-body font-bold text-xs sm:text-sm uppercase flex justify-center items-center gap-2 rounded-sm">
|
|
<span className="text-lg sm:text-xl">✗</span>
|
|
{t('executionFailed')}
|
|
</div>
|
|
) : (
|
|
<NuclearKeyButton
|
|
label={tApproval('card.authorizeExecution')}
|
|
riskLevel={nuclearRiskLevel}
|
|
onConfirm={handleAuthorize}
|
|
disabled={isExecuting}
|
|
showShortcut={true}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const Label = ({ children }: { children: React.ReactNode }) => (
|
|
<span className="text-xs uppercase font-body font-bold text-gray-500 tracking-wider">
|
|
{children}
|
|
</span>
|
|
)
|