feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { BetaAccessRequestRecord } from "@/db/repository";
|
||||
|
||||
type SubmitState = { loading: boolean; message: string; error: boolean };
|
||||
|
||||
export function AccessRequestForm({
|
||||
pendingRequest,
|
||||
}: {
|
||||
pendingRequest: BetaAccessRequestRecord | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pendingKey = useRef<string | null>(null);
|
||||
const [preferredOs, setPreferredOs] = useState("windows");
|
||||
const [requestedSlots, setRequestedSlots] = useState(1);
|
||||
const [useCase, setUseCase] = useState("");
|
||||
const [state, setState] = useState<SubmitState>({ loading: false, message: "", error: false });
|
||||
|
||||
async function post(payload: Record<string, unknown>) {
|
||||
pendingKey.current ??= crypto.randomUUID();
|
||||
const response = await fetch("/api/beta-access", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, idempotencyKey: pendingKey.current }),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "操作失败");
|
||||
pendingKey.current = null;
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await post({ action: "request", preferredOs, requestedSlots, useCase });
|
||||
setState({ loading: false, message: "申请已提交,处理结果会显示在本页。", error: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setState({ loading: false, message: error instanceof Error ? error.message : "提交失败", error: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!pendingRequest) return;
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await post({ action: "cancel", requestId: pendingRequest.id });
|
||||
setState({ loading: false, message: "申请已撤回。", error: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setState({ loading: false, message: error instanceof Error ? error.message : "撤回失败", error: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRequest) {
|
||||
return (
|
||||
<div className="cloud-form feedback-form">
|
||||
<p>申请正在等待人工处理。测试资格没有承诺顺序,也不会因此建立付费关系。</p>
|
||||
<button className="button button-secondary" type="button" onClick={cancel} disabled={state.loading}>
|
||||
{state.loading ? "正在撤回…" : "撤回这条申请"}
|
||||
</button>
|
||||
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="cloud-form feedback-form" onSubmit={submit}>
|
||||
<div className="admin-form-grid">
|
||||
<label>
|
||||
<span>计划使用的主机</span>
|
||||
<select value={preferredOs} onChange={(event) => setPreferredOs(event.target.value)}>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="both">Windows 和 Linux</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>希望接入几台</span>
|
||||
<input type="number" min={1} max={3} value={requestedSlots} onChange={(event) => setRequestedSlots(Number(event.target.value))} required />
|
||||
<small>首批申请限 1–3 台,实际名额以审核结果为准。</small>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>你准备怎么使用 NekoNest?</span>
|
||||
<textarea minLength={20} maxLength={1000} value={useCase} onChange={(event) => setUseCase(event.target.value)} placeholder="例如:我主要在 Windows 主机上使用 Codex,希望从手机查看并继续已有任务。请勿粘贴令牌、密码、项目代码或私密会话内容。" required />
|
||||
<small>{useCase.length}/1000 · 只需说明设备和使用场景,不要提交任何密钥或会话正文</small>
|
||||
</label>
|
||||
<button className="button button-primary" type="submit" disabled={state.loading}>
|
||||
{state.loading ? "正在提交…" : "申请免费闭测资格"}
|
||||
</button>
|
||||
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { requireCloudViewer } from "../../cloud-auth";
|
||||
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
|
||||
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
|
||||
import { getBillingEntitlementPresentation } from "@/db/domain";
|
||||
import { deriveInvitationDisplayState } from "@/db/invitations";
|
||||
import { AccessRequestForm } from "./AccessRequestForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BillingPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/billing");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const snapshot = await getDashboardSnapshot(account);
|
||||
const billingCopy = getBillingEntitlementPresentation(
|
||||
snapshot.entitlement.mode,
|
||||
snapshot.entitlement.publicBetaState,
|
||||
);
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const invitations = snapshot.grants.filter((grant) => grant.source === "admin_exemption");
|
||||
const pendingAccessRequest = snapshot.accessRequests.find((request) => request.status === "requested") ?? null;
|
||||
const endingSoon = invitations.find((invitation) => {
|
||||
if (deriveInvitationDisplayState(invitation, nowIso) !== "active" || !invitation.ends_at) return false;
|
||||
const remaining = new Date(invitation.ends_at).getTime() - now.getTime();
|
||||
return remaining > 0 && remaining <= 7 * 24 * 60 * 60_000;
|
||||
});
|
||||
const available = snapshot.entitlement.unlimited
|
||||
? "当前不按槽位限额"
|
||||
: `${snapshot.entitlement.availableSlots ?? 0} 个`;
|
||||
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/billing">
|
||||
<div className="cloud-page">
|
||||
<PageHeading
|
||||
eyebrow="PUBLIC BETA / 公测权益"
|
||||
title={snapshot.entitlement.mode === "none" ? "这里说明当前免费资格状态。" : "当前免费,不需要处理账单。"}
|
||||
description="这里仅显示公测资格、主机占用和透明容量规则。报价、订单与支付功能暂不开放。"
|
||||
/>
|
||||
|
||||
<section className="billing-status-panel">
|
||||
<div>
|
||||
<StatusPill tone={billingCopy.tone}>{billingCopy.status}</StatusPill>
|
||||
<h2>{billingCopy.title}</h2>
|
||||
<p>{billingCopy.description}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>启用主机</dt><dd>{snapshot.entitlement.activeSlots}</dd></div>
|
||||
<div><dt>等待配对</dt><dd>{snapshot.entitlement.reservedSlots}</dd></div>
|
||||
<div><dt>剩余容量</dt><dd>{available}</dd></div>
|
||||
<div><dt>下次资格变化</dt><dd>{formatDate(snapshot.entitlement.effectiveUntil)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{endingSoon && (
|
||||
<section className="panel full-panel attention-panel" role="status">
|
||||
<div className="panel-heading"><div><span>CLOSED BETA NOTICE</span><h2>闭测邀请即将到期</h2></div><StatusPill tone="warn">请留意</StatusPill></div>
|
||||
<p>当前邀请将在 {formatDate(endingSoon.ends_at, true)} 到期。到期后不能新增或重新认领主机;既有主机不会被自动断开,也不会因此产生账单。</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="dashboard-columns billing-columns">
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span>当前政策</span><h2>免费公测</h2></div></div>
|
||||
<div className="billing-rules">
|
||||
<span>✓ 不绑定支付方式</span>
|
||||
<span>✓ 不生成报价或订单</span>
|
||||
<span>✓ 公测结束不自动扣款</span>
|
||||
<span>✓ 容量限制提前明示</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span>未来安排</span><h2>收费以后再决定</h2></div></div>
|
||||
<p>当前先验证连接成功率、稳定性、资源成本和个人用户的真实使用频率。等数据足够,再单独设计并通知收费方案。</p>
|
||||
<div className="empty-inline">现在没有需要确认、支付或续费的项目。</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>CLOSED BETA / 申请测试</span><h2>申请免费闭测资格</h2></div>
|
||||
<StatusPill tone={pendingAccessRequest ? "warn" : snapshot.entitlement.mode === "none" ? "neutral" : "good"}>{pendingAccessRequest ? "等待处理" : snapshot.entitlement.mode === "none" ? "可申请" : "已有资格"}</StatusPill>
|
||||
</div>
|
||||
{snapshot.entitlement.mode !== "none" ? (
|
||||
<div className="empty-inline">当前账户已经具备免费测试资格,不需要重复申请。</div>
|
||||
) : (
|
||||
<AccessRequestForm pendingRequest={pendingAccessRequest} />
|
||||
)}
|
||||
{snapshot.accessRequests.length > 0 && (
|
||||
<div className="admin-gate-table invitation-history">
|
||||
{snapshot.accessRequests.map((request) => (
|
||||
<article key={request.id}>
|
||||
<StatusPill tone={request.status === "approved" ? "good" : request.status === "requested" ? "warn" : "neutral"}>{request.status === "approved" ? "已批准" : request.status === "requested" ? "审核中" : request.status === "declined" ? "暂未批准" : "已撤回"}</StatusPill>
|
||||
<div><strong>{request.requested_slots} 台 · {request.preferred_os === "both" ? "Windows 和 Linux" : request.preferred_os === "windows" ? "Windows" : "Linux"}</strong><small>{request.id}</small></div>
|
||||
<span>申请:{formatDate(request.requested_at, true)}</span>
|
||||
<span>{request.admin_response || "尚无处理说明"}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>CLOSED BETA / 我的邀请</span><h2>闭测邀请记录</h2></div><StatusPill tone={invitations.some((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active") ? "good" : "neutral"}>{invitations.filter((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active").length} 个有效</StatusPill></div>
|
||||
<p>邀请是有期限的免费接入资格,与报价、订单或支付账户无关。</p>
|
||||
<div className="admin-gate-table invitation-history">
|
||||
{invitations.length ? invitations.map((invitation) => {
|
||||
const state = deriveInvitationDisplayState(invitation, nowIso);
|
||||
return <article key={invitation.id}><StatusPill tone={state === "active" ? "good" : "neutral"}>{state === "active" ? "有效" : state === "expired" ? "已到期" : "已撤销"}</StatusPill><div><strong>{invitation.capacity_slots === null ? "当前不按主机槽位限额" : `${invitation.capacity_slots} 个主机槽位`}</strong><small>{invitation.id}</small></div><span>开始:{formatDate(invitation.starts_at, true)}</span><span>结束:{formatDate(invitation.ends_at, true)}</span></article>;
|
||||
}) : <div className="empty-inline">该账户没有闭测邀请;公开公测开放状态以页面上方为准。</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>边界</span><h2>公测结束会发生什么?</h2></div></div>
|
||||
<div className="billing-rules">
|
||||
<span>1. 免费资格按后台发布的政策变化</span>
|
||||
<span>2. 新配对和未完成认领停止,既有主机不自动断开</span>
|
||||
<span>3. 未主动确认前,不创建任何付费关系</span>
|
||||
<span>4. 撤销设备和必要的数据退出能力继续可用</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user