/** * 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 = ({ label, onConfirm, riskLevel, disabled = false, showShortcut = true, duration, approvalId, }) => { const t = useTranslations('nuclearKey') const [showSuccess, setShowSuccess] = useState(false) const holdStartTime = useRef(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 (
{t('authorized')}
) } return (
{/* Main Button - Touch-friendly sizing on mobile */} {/* Shortcut Hint - Hidden on mobile touch devices */} {showShortcut && !disabled && (
{isHolding ? ( {t('keepHolding')} ) : ( <> {/* Mobile: simplified hint */} {t('holdHintMobile')} {/* Desktop: full hint with keyboard shortcut */} {t('holdHintDesktop').split('Y').map((part, i) => i === 0 ? part : ( Y {part} ) )} )}
)} {/* Critical Warning */} {isCritical && !disabled && !isHolding && (
{t('highBlastRadius')}
)}
) } // 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) }