從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
"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<PairErrorCode, string> = {
|
||
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 (
|
||
<section
|
||
data-testid="pair-view"
|
||
aria-labelledby="pair-view-heading"
|
||
className="flex flex-col gap-6 py-4"
|
||
>
|
||
{/* 標題區(spec §5.2 A) */}
|
||
<div className="space-y-1 text-center">
|
||
<h2
|
||
id="pair-view-heading"
|
||
className="text-2xl font-semibold"
|
||
>
|
||
{t("pair.title")}
|
||
</h2>
|
||
<p className="text-muted-foreground text-sm">{t("pair.description")}</p>
|
||
</div>
|
||
|
||
{/* Token 輸入卡(spec §5.2 B-D) */}
|
||
<Card>
|
||
<CardContent className="space-y-4">
|
||
<TokenInput
|
||
value={token}
|
||
onChange={handleChange}
|
||
onSubmit={handleSubmit}
|
||
disabled={submitting}
|
||
autoFocus
|
||
/>
|
||
|
||
{/* 錯誤訊息(僅在配對失敗時顯示) */}
|
||
{lastError && (
|
||
<Alert variant="destructive" data-testid="pair-error">
|
||
<AlertDescription>{formatError(lastError)}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* 輔助連結(spec §5.2 C) */}
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="link"
|
||
size="sm"
|
||
className="h-auto p-0 text-xs"
|
||
onClick={handleOpenCloud}
|
||
data-testid="pair-link-cloud"
|
||
>
|
||
{t("pair.linkCloud")}
|
||
<ArrowUpRight aria-hidden="true" className="ml-0.5 size-3" />
|
||
</Button>
|
||
|
||
{/* 按鈕列 — 取消 / 配對 */}
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
onClick={handleCancel}
|
||
disabled={submitting}
|
||
data-testid="pair-button-cancel"
|
||
>
|
||
{t("pair.button.cancel")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="default"
|
||
onClick={handleSubmit}
|
||
disabled={!canSubmit}
|
||
aria-busy={submitting}
|
||
data-testid="pair-button-submit"
|
||
>
|
||
{submitting ? (
|
||
<>
|
||
<Spinner size="sm" label={t("pair.button.submitting")} />
|
||
<span>{t("pair.button.submitting")}</span>
|
||
</>
|
||
) : (
|
||
t("pair.button.submit")
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 底部安全提示(spec §5.2 E) */}
|
||
<Alert variant="info" data-testid="pair-alert-security">
|
||
<AlertDescription>{t("pair.alert.security")}</AlertDescription>
|
||
</Alert>
|
||
</section>
|
||
);
|
||
}
|