feat(site): 官网定价页 —— 套餐由后台配置驱动,公开可见
官网加「定价」菜单与页面。数据来自 GET /api/v1/pricing(**不挂鉴权**): 运营改价、上下架、调发放节奏都在管理端完成,官网跟着变,不用发版; 未登录就能看到价格,这是转化的前提,也是官网存在的意义。 接口只吐在售项与展示字段,成本、权重、租户信息一律不出去。 结账仍在 Web 面完成(那里有登录态、租户上下文与支付轮询),官网只负责 「看价 → 去买」。同一套支付流程不在两处各实现一遍——这也是本次订阅开发 一直遵循的那条线:积分包与订阅共用一个支付弹窗,桌面端不重复购买入口。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user