feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribeToConnectivity(onStoreChange: () => void) {
|
||||
const handleOnline = () => onStoreChange();
|
||||
const handleOffline = () => onStoreChange();
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}
|
||||
|
||||
function getOfflineSnapshot() {
|
||||
return !navigator.onLine;
|
||||
}
|
||||
|
||||
function getServerOfflineSnapshot() {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ConnectivityBanner() {
|
||||
const offline = useSyncExternalStore(
|
||||
subscribeToConnectivity,
|
||||
getOfflineSnapshot,
|
||||
getServerOfflineSnapshot,
|
||||
);
|
||||
|
||||
if (!offline) return null;
|
||||
|
||||
return (
|
||||
<div className="connectivity-banner" role="status" aria-live="polite">
|
||||
<strong>当前设备离线</strong>
|
||||
<span>
|
||||
页面内容可能已经过期。恢复网络后请重新加载;已提交的操作先核对状态,避免重复。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export function RouteLoadingState({
|
||||
area,
|
||||
description,
|
||||
}: {
|
||||
area: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<main className="route-state-shell" aria-busy="true" aria-live="polite">
|
||||
<section className="route-state-card route-loading-card">
|
||||
<span className="route-state-kicker">{area}</span>
|
||||
<h1>正在加载最新状态</h1>
|
||||
<p>{description}</p>
|
||||
<div className="route-loading-lines" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function RouteErrorState({
|
||||
area,
|
||||
title,
|
||||
description,
|
||||
reset,
|
||||
dashboard = false,
|
||||
}: {
|
||||
area: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reset: () => void;
|
||||
dashboard?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<main className="route-state-shell">
|
||||
<section className="route-state-card route-error-card" role="alert">
|
||||
<span className="route-state-kicker">{area}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<p className="route-state-note">
|
||||
这次页面失败不能证明你的主机或会话中继已经离线。先查看服务状态,再决定是否重新执行刚才的操作。
|
||||
</p>
|
||||
<div className="route-state-actions">
|
||||
<button className="button button-primary" type="button" onClick={reset}>
|
||||
重试加载
|
||||
</button>
|
||||
<Link className="button button-secondary" href="/status">
|
||||
查看服务状态
|
||||
</Link>
|
||||
<Link
|
||||
className="route-state-link"
|
||||
href={dashboard ? "/dashboard/feedback" : "/"}
|
||||
>
|
||||
{dashboard ? "仍有问题,提交反馈" : "返回首页"}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { chatGPTSignInPath, chatGPTSignOutPath } from "../chatgpt-auth";
|
||||
import { getCloudViewer, type CloudViewer } from "../cloud-auth";
|
||||
import {
|
||||
getActiveBeta,
|
||||
getServiceStatusSnapshot,
|
||||
type ServiceStatusSnapshot,
|
||||
} from "@/db/repository";
|
||||
import { ConnectivityBanner } from "./ConnectivityBanner";
|
||||
|
||||
const serviceStatusLabels = {
|
||||
operational: "服务正常",
|
||||
maintenance: "计划维护",
|
||||
degraded: "服务降级",
|
||||
outage: "服务中断",
|
||||
} as const;
|
||||
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<span className="brand-lockup">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" role="img">
|
||||
<path d="M8 16 5 6l11 6h8L35 6l-3 10v10c0 6-5 10-12 10S8 32 8 26V16Z" />
|
||||
<path d="M14 23h.1M26 23h.1M16 29c2.4 1.8 5.6 1.8 8 0" />
|
||||
</svg>
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="brand-type">
|
||||
<strong>NekoNest</strong>
|
||||
<span>Cloud</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
tone?: "good" | "warn" | "danger" | "neutral" | "info";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <span className={`status-pill status-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
export async function PublicShell({
|
||||
children,
|
||||
serviceStatus: providedServiceStatus,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
serviceStatus?: ServiceStatusSnapshot;
|
||||
}) {
|
||||
const [viewer, beta, serviceStatus] = await Promise.all([
|
||||
getCloudViewer(),
|
||||
getActiveBeta(),
|
||||
providedServiceStatus ?? getServiceStatusSnapshot(),
|
||||
]);
|
||||
return (
|
||||
<div className="site-shell">
|
||||
<header className="public-header">
|
||||
<div className="public-header-inner">
|
||||
<Link className="brand-link" href="/" aria-label="NekoNest Cloud 首页">
|
||||
<Brand />
|
||||
</Link>
|
||||
<nav className="public-nav" aria-label="主要导航">
|
||||
<Link href="/#how">工作原理</Link>
|
||||
<Link href="/download">下载</Link>
|
||||
<Link href="/pricing">公测</Link>
|
||||
<Link href="/trust">信任边界</Link>
|
||||
<Link href="/privacy">数据说明</Link>
|
||||
<Link href="/readiness">上线门禁</Link>
|
||||
<Link href="/status">服务状态</Link>
|
||||
</nav>
|
||||
<div className="header-actions">
|
||||
<span className="beta-chip">公开原型 · {beta ? "公测免费" : "公测已结束"}</span>
|
||||
{viewer ? (
|
||||
<Link className="button button-small button-primary" href="/dashboard">
|
||||
打开控制台
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
className="button button-small button-primary"
|
||||
href={chatGPTSignInPath("/dashboard")}
|
||||
>
|
||||
登录控制台
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<ConnectivityBanner />
|
||||
<main>{children}</main>
|
||||
<footer className="public-footer">
|
||||
<div className="footer-grid">
|
||||
<div>
|
||||
<Brand />
|
||||
<p>把电脑上的 coding-agent,接回手机继续。</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>产品</strong>
|
||||
<Link href="/download">下载主机端</Link>
|
||||
<Link href="/pricing">免费公测</Link>
|
||||
<Link href="/trust">安全与隐私边界</Link>
|
||||
<Link href="/privacy">公测数据说明</Link>
|
||||
<Link href="/readiness">上线准备度</Link>
|
||||
<Link href="/status">服务状态</Link>
|
||||
</div>
|
||||
<div>
|
||||
<strong>边界</strong>
|
||||
<p>自托管永久免费;Cloud 不运行模型,不卖 Token,不设积分。</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>经营信息</strong>
|
||||
<p>主体与备案路径仍待确认;公测免费,收费功能暂不开放。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>© 2026 NekoNest Cloud prototype</span>
|
||||
<span>{serviceStatusLabels[serviceStatus.status]} · 页面内容不是正式收费或合规承诺</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dashboardNav = [
|
||||
{ href: "/dashboard", label: "总览", icon: "⌂" },
|
||||
{ href: "/dashboard/hosts", label: "主机", icon: "▣" },
|
||||
{ href: "/dashboard/billing", label: "公测权益", icon: "○" },
|
||||
{ href: "/dashboard/security", label: "安全与设备", icon: "◇" },
|
||||
{ href: "/dashboard/feedback", label: "问题反馈", icon: "?" },
|
||||
] as const;
|
||||
|
||||
export async function DashboardShell({
|
||||
viewer,
|
||||
active,
|
||||
children,
|
||||
serviceStatus: providedServiceStatus,
|
||||
}: {
|
||||
viewer: CloudViewer;
|
||||
active: string;
|
||||
children: ReactNode;
|
||||
serviceStatus?: ServiceStatusSnapshot;
|
||||
}) {
|
||||
const serviceStatus =
|
||||
providedServiceStatus ?? (await getServiceStatusSnapshot());
|
||||
return (
|
||||
<div className="cloud-shell">
|
||||
<aside className="cloud-sidebar">
|
||||
<Link className="brand-link cloud-brand" href="/">
|
||||
<Brand />
|
||||
</Link>
|
||||
<div className="prototype-notice">
|
||||
<span className="notice-dot" />
|
||||
控制平面原型
|
||||
</div>
|
||||
<nav className="cloud-nav" aria-label="控制台导航">
|
||||
{dashboardNav.map((item) => (
|
||||
<Link
|
||||
className={active === item.href ? "active" : undefined}
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{viewer.isAdmin && (
|
||||
<Link className={active === "/admin" ? "active" : undefined} href="/admin">
|
||||
<span aria-hidden="true">⚙</span>
|
||||
公测后台
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-account">
|
||||
<span className="account-avatar" aria-hidden="true">
|
||||
{viewer.displayName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{viewer.displayName}</strong>
|
||||
<small>{viewer.isLocalDemo ? "本地演示身份" : viewer.email}</small>
|
||||
</span>
|
||||
{!viewer.isLocalDemo && (
|
||||
<Link href={chatGPTSignOutPath("/")} aria-label="退出登录">
|
||||
↗
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="cloud-main">
|
||||
<ConnectivityBanner />
|
||||
{serviceStatus.activeIncidents.length > 0 && (
|
||||
<div
|
||||
className={`service-incident-banner incident-${serviceStatus.status}`}
|
||||
role="status"
|
||||
>
|
||||
<span>
|
||||
<strong>{serviceStatusLabels[serviceStatus.status]}</strong>
|
||||
{serviceStatus.activeIncidents[0].title}
|
||||
</span>
|
||||
<Link href="/status">查看详情 →</Link>
|
||||
</div>
|
||||
)}
|
||||
{viewer.isLocalDemo && (
|
||||
<div className="demo-banner" role="status">
|
||||
当前使用本地演示身份;正式托管环境会要求登录,演示数据不会代表真实在线服务。
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="page-heading">
|
||||
<div>
|
||||
{eyebrow && <span className="eyebrow">{eyebrow}</span>}
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMoney(amountMinor: number, currency = "CNY") {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amountMinor / 100);
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null, includeTime = false) {
|
||||
if (!value) return "未设定";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(includeTime ? { hour: "2-digit", minute: "2-digit" } : {}),
|
||||
}).format(new Date(value));
|
||||
}
|
||||
Reference in New Issue
Block a user