"use client"; /** * PairView — 配對頁(AF4 實作) * * 對齊 spec §5: * - 標題 + description * - Token 輸入卡(使用抽出的 TokenInput 元件:格式驗證 + trim + Enter 送出) * - 輔助連結(到雲端網頁產生)— Phase 0 URL 寫死 placeholder(spec §5.2 C 明列) * - 按鈕列(取消 / 配對,含 loading state) * - Alert 底部提示(Token 15 分鐘有效) * - 錯誤訊息對照(7 種,對應 PairErrorCode) * - 成功 → 0.5s 後自動切到狀態頁 + toast「配對成功」(D2 決議,spec §5.3) * * 資料來源: * - usePair() → pair(token) Promise; submitting; lastError; reset() */ import { useEffect, useRef, useState } from "react"; import { ArrowUpRight } from "lucide-react"; import { toast } from "sonner"; import { TokenInput, validateToken } from "@/components/agent/token-input"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { usePair } from "@/hooks/use-pair"; import { agentAPI } from "@/lib/agent-api"; import { useT } from "@/lib/i18n/context"; import type { PairError, PairErrorCode } from "@/types/agent"; import { AGENT_SWITCH_TAB_EVENT } from "./status-view"; /** 雲端產生 token 的 URL(Phase 0 placeholder;正式上線改為環境變數驅動)。 */ const CLOUD_PAIR_URL = "https://visionA.cloud/devices/pair"; /** 成功後切回狀態頁的延遲(spec §5.3「0.5 秒後自動切回」)。 */ const PAIR_SUCCESS_REDIRECT_MS = 500; /** PairErrorCode → i18n key 對照(spec §5.4)。 */ const ERROR_I18N_KEY: Record = { token_expired: "pair.error.tokenExpired", token_used: "pair.error.tokenUsed", token_invalid: "pair.error.tokenInvalid", token_revoked: "pair.error.tokenRevoked", network_error: "pair.error.network", relay_unreachable: "pair.error.relayUnreachable", unknown: "pair.error.unknown", }; /** 切回狀態頁(透過 StatusView 定義的 CustomEvent)。 */ function goBackToStatus() { if (typeof window === "undefined") return; window.dispatchEvent( new CustomEvent<{ value: string }>(AGENT_SWITCH_TAB_EVENT, { detail: { value: "status" }, }), ); } export function PairView() { const t = useT(); const { pair, submitting, lastError, reset } = usePair(); const [token, setToken] = useState(""); const validity = validateToken(token); const canSubmit = validity === "valid" && !submitting; /** * spec §5.5 / Fix-F2:Esc 鍵 cancel → 切回狀態頁。 * * - 用 ref 把 submitting 的最新值保留給 listener(避免 stale closure 重綁 listener) * - submitting=true 時 Esc 不動(避免使用者誤操作打斷送出中的請求) * - mount 才綁、unmount 解綁,避免 memory leak / 跨 view 殘留 * - 雛形階段不做「未儲存提示」確認對話(spec 沒要求) */ const submittingRef = useRef(submitting); useEffect(() => { submittingRef.current = submitting; }, [submitting]); useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key !== "Escape") return; if (submittingRef.current) return; e.preventDefault(); // 不呼叫 handleCancel(會碰到 stale closure 的 reset/setToken; // 這裡直接切 tab 即可,token state 隨 unmount 一起釋放) goBackToStatus(); } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); /** 將 PairError 轉為使用者可見的字串(處理變數內插)。 */ function formatError(err: PairError): string { const raw = t(ERROR_I18N_KEY[err.code]); if (err.code === "relay_unreachable" && err.relayUrl) { return raw.replace("{url}", err.relayUrl); } return raw; } async function handleSubmit() { if (!canSubmit) return; try { await pair(token); toast.success(t("pair.success")); // D2 決議:0.5 秒後自動切回狀態頁 window.setTimeout(goBackToStatus, PAIR_SUCCESS_REDIRECT_MS); } catch { // lastError 會由 hook 自動設定;此處不做額外處理 // (toast 不顯示;錯誤由頁內 Alert 呈現,避免雙重通知) } } function handleCancel() { setToken(""); reset(); goBackToStatus(); } function handleChange(next: string) { setToken(next); // 使用者修改 token → 清掉上次錯誤(避免舊錯誤誤導) if (lastError) reset(); } function handleOpenCloud() { // Fix-F3:統一走 agentAPI.openURL;Wails runtime 會用 BrowserOpenURL, // mock 環境 fallback 到 window.open agentAPI.openURL(CLOUD_PAIR_URL); } return (
{/* 標題區(spec §5.2 A) */}

{t("pair.title")}

{t("pair.description")}

{/* Token 輸入卡(spec §5.2 B-D) */} {/* 錯誤訊息(僅在配對失敗時顯示) */} {lastError && ( {formatError(lastError)} )} {/* 輔助連結(spec §5.2 C) */}
{/* 按鈕列 — 取消 / 配對 */}
{/* 底部安全提示(spec §5.2 E) */} {t("pair.alert.security")}
); }