"use client"; import { useEffect, useRef, useState } from "react"; import { PairingCancelButton } from "../PairingCancelButton"; import { buildDaemonRegistrationCommand, daemonStartCommand, type SupportedHostOS, } from "./onboarding"; import type { PairingAccessState } from "../../onboarding"; type PairingResult = { id: string; bootstrapToken: string; expiresAt: string }; type PairingView = PairingResult & { requestedName: string; os: SupportedHostOS; }; type PairingProgress = { id: string; status: "waiting" | "claimed" | "expired" | "locked" | "cancelled"; expiresAt: string; claimedHostId: string | null; claimedAt: string | null; claimAttemptState: "not_seen" | "seen" | "invalid"; lastClaimAttemptAt: string | null; }; type PairingTerminal = Exclude; type PairingCompletion = { hostId: string; claimedAt: string; }; type CopyTarget = "command" | "token" | "start"; const unavailableCopy: Record, { title: string; detail: string; href: string; action: string; readiness?: boolean }> = { request_pending: { title: "免费闭测申请正在审核", detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。", href: "/dashboard/billing", action: "查看申请进度", }, gated: { title: "公开接入仍由安全门禁阻止", detail: "免费政策已经预设,但 P0 证据尚未齐全。可以申请小范围免费闭测资格。", href: "/dashboard/billing", action: "申请免费闭测", readiness: true, }, inactive: { title: "公开公测当前未开放", detail: "该账户没有有效的免费公测或闭测邀请;可以提交闭测申请,不会因此创建订单或要求付款。", href: "/dashboard/billing", action: "申请免费闭测", }, full: { title: "当前没有可用主机槽位", detail: "已有主机和等待中的配对请求已经占满当前免费容量,请先取消旧请求或联系管理员调整邀请。", href: "/dashboard/hosts", action: "管理主机和配对", }, }; const terminalCopy: Record = { expired: { title: "这枚配对码已经过期", detail: "十分钟有效期已经结束,原码不能恢复。确认 daemon 已准备好后再生成一枚新码。", }, locked: { title: "这枚配对码已经锁定", detail: "错误尝试次数已达到上限,原码不能继续使用。请核对 daemon 和复制步骤后重新生成。", }, cancelled: { title: "这枚配对码已经取消", detail: "原码已失效并释放预留容量;需要接入时可以重新生成。", }, }; export function PairingForm({ accessState, releaseAvailable, minimumDaemonVersion, connectOrigin, }: { accessState: PairingAccessState; releaseAvailable: boolean; minimumDaemonVersion: string; connectOrigin: string; }) { const [name, setName] = useState(""); const [os, setOs] = useState("windows"); const [pairing, setPairing] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [copied, setCopied] = useState(null); const [copyError, setCopyError] = useState(""); const [closedBetaBuildConfirmed, setClosedBetaBuildConfirmed] = useState(false); const [progressError, setProgressError] = useState(""); const [completion, setCompletion] = useState(null); const [terminal, setTerminal] = useState(null); const [claimAttempt, setClaimAttempt] = useState>({ claimAttemptState: "not_seen", lastClaimAttemptAt: null }); const resultHeading = useRef(null); const pairingId = pairing?.id ?? null; useEffect(() => { if (pairing) resultHeading.current?.focus(); }, [pairing]); useEffect(() => { if (!pairingId) return; let active = true; let timer: number | undefined; async function poll() { try { const response = await fetch( `/api/hosts/pairing?pairing_id=${encodeURIComponent(pairingId!)}`, { cache: "no-store" }, ); const body = (await response.json()) as { pairing?: PairingProgress; message?: string; }; if (!active) return; if (!response.ok || !body.pairing) { throw new Error(body.message || "暂时无法确认配对状态"); } setProgressError(""); setClaimAttempt({ claimAttemptState: body.pairing.claimAttemptState, lastClaimAttemptAt: body.pairing.lastClaimAttemptAt, }); if (body.pairing.status === "claimed" && body.pairing.claimedHostId && body.pairing.claimedAt) { setCompletion({ hostId: body.pairing.claimedHostId, claimedAt: body.pairing.claimedAt, }); setPairing(null); setCopied(null); setCopyError(""); return; } if ( body.pairing.status === "expired" || body.pairing.status === "locked" || body.pairing.status === "cancelled" ) { setTerminal(body.pairing.status); setPairing(null); setCopied(null); setCopyError(""); return; } timer = window.setTimeout(poll, 2_000); } catch (pollError) { if (!active) return; setProgressError( pollError instanceof Error ? `${pollError.message};页面会继续重试。` : "暂时无法确认配对状态;页面会继续重试。", ); timer = window.setTimeout(poll, 5_000); } } timer = window.setTimeout(poll, 1_000); return () => { active = false; if (timer !== undefined) window.clearTimeout(timer); }; }, [pairingId]); if (accessState !== "available") { const copy = unavailableCopy[accessState]; return (
×

{copy.title}

{copy.detail}

); } async function copyText(value: string, target: CopyTarget) { setCopyError(""); try { if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable"); await navigator.clipboard.writeText(value); setCopied(target); } catch { setCopied(null); setCopyError("浏览器未允许自动复制。请点入对应文本框,使用 Ctrl+C 或系统复制操作手动复制。"); } } async function submit(event: React.FormEvent) { event.preventDefault(); setError(""); setTerminal(null); setCompletion(null); if (!releaseAvailable && !closedBetaBuildConfirmed) { setError("公开下载尚未就绪。请先取得并核验兼容的闭测构建,再确认后生成配对码。"); return; } setLoading(true); try { const response = await fetch("/api/hosts/pairing", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestedName: name, os }), }); const body = (await response.json()) as { pairing?: PairingResult; message?: string }; if (!response.ok || !body.pairing) throw new Error(body.message || "无法创建配对请求"); setPairing({ ...body.pairing, requestedName: name.trim(), os, }); setCopied(null); setCopyError(""); setProgressError(""); setClaimAttempt({ claimAttemptState: "not_seen", lastClaimAttemptAt: null }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "无法创建配对请求"); } finally { setLoading(false); } } if (completion) { return (

主机已成功认领

一次性配对码已经从页面状态中清除。控制平面已建立可撤销设备凭据并推进授权 revision;daemon 会继续连接同一个服务地址。

认领时间:{new Date(completion.claimedAt).toLocaleString("zh-CN")} · 主机编号 {completion.hostId}
查看主机与开通状态
); } if (terminal) { const copy = terminalCopy[terminal]; return (
×

{copy.title}

{copy.detail}

查看主机和配对
); } if (pairing) { const registrationCommand = buildDaemonRegistrationCommand({ os: pairing.os, connectOrigin, hostName: pairing.requestedName, }); const startCommand = daemonStartCommand(pairing.os); return (
PAIRING REQUEST CREATED

在 {pairing.os === "windows" ? "Windows" : "Linux"} 主机完成注册

命令已按 Cloud 稳定 Connect 服务地址和主机名生成。一次性码不会拼进命令、URL 或浏览器存储。

{claimAttempt.claimAttemptState === "seen" ? "Cloud 已收到请求,但尚未认领" : claimAttempt.claimAttemptState === "invalid" ? "认领尝试时间待核实" : "尚未收到注册请求"} {claimAttempt.claimAttemptState === "seen" ? `最近一次尝试:${new Date(claimAttempt.lastClaimAttemptAt!).toLocaleString("zh-CN")}。这只证明 Cloud 收到过针对该配对 ID 的请求,不证明来源一定是你的 daemon。请先查看终端错误,并核对系统类型、配对码、daemon 版本和 identity.json;当前码有效时无需反复生成。` : claimAttempt.claimAttemptState === "invalid" ? "Cloud 找到过针对这枚配对请求的记录,但时间异常,不能判断先后;请查看终端错误或提交反馈。" : "请先在目标主机运行注册命令并粘贴一次性码。页面每两秒确认一次,尚未收到请求通常表示命令未运行、地址不可达或请求还没发出。"}
{progressError &&

{progressError}

} 还没有兼容 daemon?先查看下载与 SHA-256 校验 →
  1. 复制命令,在 daemon 所在目录运行