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>
302 lines
8.2 KiB
TypeScript
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)
|
|
}
|