Files
awoooi/apps/web/src/components/genui/NuclearKeyButton.tsx
OG T 4d46e6b9a7
Some checks failed
E2E Health Check / e2e-health (push) Successful in 17s
CD Pipeline / build-and-deploy (push) Has been cancelled
style(web): 全站 font-mono → font-body (DM Mono 設計系統套用)
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>
2026-04-02 09:37:03 +08:00

302 lines
8.2 KiB
TypeScript

/**
* NuclearKeyButton - 核鑰授權按鈕
* =================================
* Phase 19.5 - 核鑰 UX 強化
*
* 高風險操作的儀式感授權按鈕:
* - 長按 N 秒確認
* - 進度條視覺回饋
* - 危險感視覺設計
* - 支援 Y 鍵快捷鍵
*
* 響應式設計 (Phase 19.R):
* - Mobile: 觸控友善,隱藏快捷鍵提示
* - Desktop: 顯示完整快捷鍵提示
*
* @see AWOOOI_AGENTIC_WORKSPACE_ROADMAP.md - Nuclear Key UX
* @see ADR-032 GenUI Dynamic Rendering
* @author Claude Code (首席架構師)
* @version 1.1.0 - 響應式設計
* @date 2026-03-28 (台北時間)
*/
'use client'
import React, { useEffect, useState, useRef } from 'react'
import { AlertTriangle, CheckCircle, Shield, ShieldAlert } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useHoldToConfirm } from '@/hooks/useHoldToConfirm'
import { trackNuclearKey } from '@/lib/telemetry'
interface NuclearKeyButtonProps {
/** 操作說明 */
label: string
/** 確認後的回調 */
onConfirm: () => void
/** 風險等級 */
riskLevel: 'low' | 'medium' | 'high' | 'critical'
/** 是否禁用 */
disabled?: boolean
/** 是否顯示快捷鍵提示 */
showShortcut?: boolean
/** 自定義持續時間 (覆蓋風險等級) */
duration?: number
/** 授權 ID (用於追蹤,可選) */
approvalId?: string
}
const RISK_CONFIG = {
low: {
label: 'LOW',
bgColor: 'bg-green-50',
borderColor: 'border-green-500',
textColor: 'text-green-700',
progressColor: 'bg-green-500',
icon: Shield,
},
medium: {
label: 'MEDIUM',
bgColor: 'bg-yellow-50',
borderColor: 'border-yellow-500',
textColor: 'text-yellow-700',
progressColor: 'bg-yellow-500',
icon: Shield,
},
high: {
label: 'HIGH',
bgColor: 'bg-orange-50',
borderColor: 'border-orange-500',
textColor: 'text-orange-700',
progressColor: 'bg-orange-500',
icon: ShieldAlert,
},
critical: {
label: 'CRITICAL',
bgColor: 'bg-red-50',
borderColor: 'border-red-600',
textColor: 'text-red-700',
progressColor: 'bg-red-600',
icon: AlertTriangle,
},
}
export const NuclearKeyButton: React.FC<NuclearKeyButtonProps> = ({
label,
onConfirm,
riskLevel,
disabled = false,
showShortcut = true,
duration,
approvalId,
}) => {
const t = useTranslations('nuclearKey')
const [showSuccess, setShowSuccess] = useState(false)
const holdStartTime = useRef<number>(0)
const trackingId = approvalId || `nuclear-${Date.now()}`
const config = RISK_CONFIG[riskLevel]
const Icon = config.icon
const handleConfirm = () => {
const holdDuration = Date.now() - holdStartTime.current
// 追蹤授權完成 (Phase 19.O)
trackNuclearKey({
approvalId: trackingId,
riskLevel,
action: 'completed',
holdDuration,
success: true,
})
setShowSuccess(true)
onConfirm()
}
const {
progress,
isHolding,
isConfirmed,
buttonProps,
reset,
} = useHoldToConfirm({
riskLevel,
duration,
onConfirm: handleConfirm,
enableKeyboard: !disabled,
})
// 追蹤按住開始/取消 (Phase 19.O)
useEffect(() => {
if (isHolding) {
holdStartTime.current = Date.now()
trackNuclearKey({
approvalId: trackingId,
riskLevel,
action: 'started',
})
} else if (holdStartTime.current > 0 && !isConfirmed) {
// 取消 (放開但未完成)
const holdDuration = Date.now() - holdStartTime.current
trackNuclearKey({
approvalId: trackingId,
riskLevel,
action: 'cancelled',
holdDuration,
})
holdStartTime.current = 0
}
}, [isHolding, isConfirmed, trackingId, riskLevel])
// 成功後 2 秒重置
useEffect(() => {
if (showSuccess) {
const timer = setTimeout(() => {
setShowSuccess(false)
reset()
}, 2000)
return () => clearTimeout(timer)
}
}, [showSuccess, reset])
const isCritical = riskLevel === 'critical' || riskLevel === 'high'
if (isConfirmed && showSuccess) {
return (
<div className={`
w-full py-4 px-6 rounded-sm border-2
bg-green-50 border-green-500
flex items-center justify-center gap-2
font-body font-bold text-green-700
transition-all duration-300
`}>
<CheckCircle size={20} />
<span className="uppercase tracking-wider">{t('authorized')}</span>
</div>
)
}
return (
<div className="space-y-2">
{/* Main Button - Touch-friendly sizing on mobile */}
<button
{...buttonProps}
disabled={disabled}
className={`
relative w-full py-3 sm:py-4 px-4 sm:px-6 rounded-sm border-2 overflow-hidden
${config.bgColor} ${config.borderColor}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
transition-all duration-150
${isHolding ? 'scale-[0.98]' : ''}
${isCritical && !disabled ? 'animate-pulse-subtle' : ''}
touch-manipulation
`}
aria-label={`${label} - ${config.label} risk level`}
style={{
// 紅黑條紋警示 (critical 時)
backgroundImage: isCritical && isHolding
? 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 20px)'
: undefined,
}}
>
{/* Progress Bar */}
<div
className={`
absolute left-0 top-0 h-full ${config.progressColor}
transition-all duration-75
`}
style={{
width: `${progress * 100}%`,
opacity: isHolding ? 0.3 : 0,
}}
/>
{/* Content */}
<div className="relative flex items-center justify-center gap-2 sm:gap-3 flex-wrap">
<Icon
size={18}
className={`${config.textColor} ${isHolding ? 'animate-pulse' : ''} sm:w-5 sm:h-5`}
/>
<span className={`font-body font-bold uppercase tracking-wider text-sm sm:text-base ${config.textColor}`}>
{isHolding ? (
<span className="tabular-nums">
{label} ({Math.ceil((1 - progress) * (duration ?? 1500) / 1000)}s)
</span>
) : (
label
)}
</span>
{/* Risk Badge */}
<span className={`
px-1.5 sm:px-2 py-0.5 text-[10px] sm:text-xs font-body font-bold
border ${config.borderColor} ${config.textColor}
rounded-sm
`}>
{config.label}
</span>
</div>
</button>
{/* Shortcut Hint - Hidden on mobile touch devices */}
{showShortcut && !disabled && (
<div className="text-center text-xs font-body text-gray-400">
{isHolding ? (
<span className="text-gray-600">
{t('keepHolding')}
</span>
) : (
<>
{/* Mobile: simplified hint */}
<span className="sm:hidden">
{t('holdHintMobile')}
</span>
{/* Desktop: full hint with keyboard shortcut */}
<span className="hidden sm:inline">
{t('holdHintDesktop').split('Y').map((part, i) =>
i === 0 ? part : (
<span key={i}>
<kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded text-gray-600">Y</kbd>
{part}
</span>
)
)}
</span>
</>
)}
</div>
)}
{/* Critical Warning */}
{isCritical && !disabled && !isHolding && (
<div className={`
text-center text-xs font-body ${config.textColor}
animate-pulse
`}>
<AlertTriangle size={12} className="inline mr-1" />
{t('highBlastRadius')}
</div>
)}
</div>
)
}
// Custom animation for subtle pulse
const style = `
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.9; }
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
`
// Inject style
if (typeof document !== 'undefined') {
const styleEl = document.createElement('style')
styleEl.textContent = style
document.head.appendChild(styleEl)
}