feat(site): 官网定价页 —— 套餐由后台配置驱动,公开可见

官网加「定价」菜单与页面。数据来自 GET /api/v1/pricing(**不挂鉴权**):
运营改价、上下架、调发放节奏都在管理端完成,官网跟着变,不用发版;
未登录就能看到价格,这是转化的前提,也是官网存在的意义。

接口只吐在售项与展示字段,成本、权重、租户信息一律不出去。

结账仍在 Web 面完成(那里有登录态、租户上下文与支付轮询),官网只负责
「看价 → 去买」。同一套支付流程不在两处各实现一遍——这也是本次订阅开发
一直遵循的那条线:积分包与订阅共用一个支付弹窗,桌面端不重复购买入口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 16:59:58 +08:00
parent a34d7045f1
commit b1fea23a0c
5 changed files with 167 additions and 0 deletions
+2
View File
@@ -8,6 +8,7 @@ import { me, clearToken, type AuthUser } from "./api";
import { SiteLayout } from "./site/components/layout/site-layout";
import HomePage from "./site/pages/home";
import DownloadPage from "./site/pages/download";
import PricingPage from "./site/pages/pricing";
import NotFoundPage from "./site/pages/not-found";
// 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。
@@ -54,6 +55,7 @@ export default function App() {
{/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer */}
<Route element={<SiteLayout />}>
<Route index element={<HomePage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="download" element={<DownloadPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
{ label: '产品', to: '/', end: true },
{ label: '功能', to: '/#features' },
{ label: '架构', to: '/#architecture' },
{ label: '定价', to: '/pricing' },
]
export function SiteHeader() {
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react'
import { GATEWAY } from '../../api'
// 官网定价页。数据来自后台配置的「订阅套餐 / 积分包」,**不写死在前端**——
// 运营改价、上下架、调发放节奏都在管理端完成,这里跟着变,不用发版。
//
// 公开可见(/api/v1/pricing 不挂鉴权):让人在登录前就看得到价格,
// 这是购买转化的前提,也是官网存在的意义。
const MICRO = 1_000_000
const credits = (m: number) => (m / MICRO).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
const yuan = (fen: number) => (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: fen % 100 === 0 ? 0 : 2 })
interface SubPlan {
id: string
name: string
price_fen: number
duration_days: number
refill_credits_micro: number
refill_interval_days: number
}
interface Pack {
id: string
name: string
credits_micro: number
price_fen: number
}
// 结账在 Web 面完成(那里有登录态、租户上下文与支付轮询)。官网只负责"看价 → 去买"
// 避免同一套支付流程在两处各实现一遍。
const CHECKOUT = '/usage'
export default function PricingPage() {
const [plans, setPlans] = useState<SubPlan[]>([])
const [packs, setPacks] = useState<Pack[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
useEffect(() => {
// GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite
fetch(`${GATEWAY}/api/v1/pricing`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d: { plans?: SubPlan[]; packs?: Pack[] }) => {
setPlans(d.plans ?? [])
setPacks(d.packs ?? [])
setState('ready')
})
.catch(() => setState('error'))
}, [])
return (
<main className="mx-auto max-w-6xl px-6 py-16 md:py-24">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">PRICING</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-ink md:text-4xl"></h1>
<p className="mt-4 text-sm leading-relaxed text-ink-2">
</p>
</header>
{state === 'loading' && <p className="mt-12 text-sm text-ink-3"></p>}
{state === 'error' && (
<p className="mt-12 text-sm text-ink-3">
<a href={CHECKOUT} className="ml-1 text-accent underline underline-offset-4">
</a>
</p>
)}
{state === 'ready' && (
<>
{plans.length > 0 && (
<section className="mt-12">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2">
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((p) => {
const times = p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0
return (
<article
key={p.id}
className="flex flex-col rounded-2xl border border-hairline bg-surface p-6 transition-colors hover:border-hairline-strong"
>
<h3 className="text-base font-semibold text-ink">{p.name}</h3>
<div className="mt-3 flex items-baseline gap-1">
<span className="text-3xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</span>
<span className="text-sm text-ink-3">/ {p.duration_days} </span>
</div>
<ul className="mt-5 space-y-2 text-sm text-ink-2">
<li>
{p.refill_interval_days} {' '}
<b className="text-ink">{credits(p.refill_credits_micro)}</b>
</li>
<li>
{times} {' '}
<b className="text-ink">{credits(p.refill_credits_micro * times)}</b>
</li>
<li className="text-ink-3"></li>
</ul>
<a
href={CHECKOUT}
className="mt-6 inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
>
</a>
</article>
)
})}
</div>
</section>
)}
{packs.length > 0 && (
<section className="mt-14">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2"></p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{packs.map((p) => (
<article key={p.id} className="rounded-2xl border border-hairline bg-surface p-5">
<h3 className="text-sm font-semibold text-ink">{p.name}</h3>
<div className="mt-2 text-2xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</div>
<p className="mt-1 text-sm text-ink-2">{credits(p.credits_micro)} </p>
<a
href={CHECKOUT}
className="mt-4 inline-flex text-sm font-medium text-accent underline underline-offset-4"
>
</a>
</article>
))}
</div>
</section>
)}
{plans.length === 0 && packs.length === 0 && (
<p className="mt-12 text-sm text-ink-3">线</p>
)}
<p className="mt-14 text-xs leading-relaxed text-ink-3">
</p>
</>
)}
</main>
)
}