Files

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));
}