258 lines
8.0 KiB
TypeScript
258 lines
8.0 KiB
TypeScript
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));
|
|
}
|