feat: P0d 会员定价页重构 — 用量可视化 + 方案对比

- 三栏定价卡片(免费/月卡/年卡),参考 opencode Go 页面风格
- 日配额用量进度条
- 功能对比表(沙盒/模型/提示词/课程/广告)
- 当前方案标记 + 最受欢迎标记
- useT 全量翻译
- 补充中/英 30+ 定价相关翻译 key
This commit is contained in:
yuzhiran-dev
2026-05-18 10:37:58 +08:00
parent a4c7d71931
commit 0fdad71c57
3 changed files with 147 additions and 84 deletions
+83 -84
View File
@@ -4,61 +4,58 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '../../../lib/auth'; import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
interface Subscription { interface Subscription {
id: number; id: number; plan: string; startDate: string; endDate: string; status: string;
plan: string;
startDate: string;
endDate: string;
status: string;
} }
interface Order { interface Order {
id: number; id: number; orderNo: string; amount: number; planType: string; status: string; createdAt: string;
orderNo: string;
amount: number;
planType: string;
status: string;
createdAt: string;
} }
const PLANS = [
{ id: 'FREE', nameKey: 'planFree' as const, price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] as const },
{ id: 'MONTHLY', nameKey: 'planMonthly' as const, price: 29.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] as const },
{ id: 'YEARLY', nameKey: 'planYearly' as const, price: 199, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] as const },
];
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
export default function MemberPage() { export default function MemberPage() {
const t = useT();
const [subscription, setSubscription] = useState<Subscription | null>(null); const [subscription, setSubscription] = useState<Subscription | null>(null);
const [orders, setOrders] = useState<Order[]>([]); const [orders, setOrders] = useState<Order[]>([]);
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [payLoading, setPayLoading] = useState(false); const [payLoading, setPayLoading] = useState<string | null>(null);
useEffect(() => { useEffect(() => { loadData(); }, []);
loadData();
}, []);
async function loadData() { async function loadData() {
try { try {
const [subRes, ordersRes] = await Promise.all([ const [subRes, ordersRes, quotaRes] = await Promise.all([
apiFetch('/subscriptions/current').catch(() => ({ ok: false })), apiFetch('/subscriptions/current').catch(() => ({ ok: false })),
apiFetch('/orders'), apiFetch('/orders'),
apiFetch('/sandbox/quota'),
]); ]);
if (subRes.ok) setSubscription(await subRes.json());
if (subRes.ok) {
const subData = await subRes.json();
setSubscription(subData);
}
const ordersData = await ordersRes.json(); const ordersData = await ordersRes.json();
setOrders(ordersData.items || []); setOrders(ordersData.items || []);
const quotaData = await quotaRes.json();
if (quotaData.remaining !== undefined) setQuota(quotaData);
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
setLoading(false); setLoading(false);
} }
async function handleSubscribe(planType: string) { async function handleSubscribe(planType: string) {
setPayLoading(true); setPayLoading(planType);
try { try {
const res = await apiFetch('/orders/create', { const res = await apiFetch('/orders/create', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
amount: planType === 'MONTHLY' ? 29.9 : 199, amount: planType === 'MONTHLY' ? 29.9 : 199,
planType, planType, payChannel: 'wxpay',
payChannel: 'wxpay',
}), }),
}); });
const data = await res.json(); const data = await res.json();
@@ -67,91 +64,93 @@ export default function MemberPage() {
loadData(); loadData();
} }
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
setPayLoading(false); setPayLoading(null);
} }
if (loading) return ( if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" /> <Skeleton className="h-8 w-48 mb-2" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8"> <Skeleton className="h-5 w-64 mb-8" />
<Skeleton className="h-24 rounded-xl" /> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Skeleton className="h-24 rounded-xl" /> {[1,2,3].map(i => <Skeleton key={i} className="h-72 rounded-2xl" />)}
<Skeleton className="h-24 rounded-xl" />
</div> </div>
<Skeleton className="h-64 w-full rounded-xl" />
</div> </div>
); );
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8"> <div className="mb-8">
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block"> <Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; </Link>
&larr; <h1 className="text-3xl font-bold text-foreground">{t.member.title}</h1>
</Link> <p className="mt-2 text-muted-foreground">{t.member.desc}</p>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div> </div>
<div className="bg-card rounded-2xl border border-border p-6 mb-8"> {quota && (
<h2 className="text-lg font-semibold text-foreground mb-4"></h2> <div className="bg-card rounded-2xl border border-border p-6 mb-8">
{subscription ? ( <div className="flex items-center justify-between mb-2">
<div> <span className="text-sm font-medium text-foreground">{t.member.dailyQuota}</span>
<div className="flex items-center gap-3 mb-4"> <span className="text-sm text-muted-foreground">{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}</span>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
subscription.plan === 'YEARLY' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}>
{subscription.plan === 'YEARLY' ? '年卡会员' : '月卡会员'}
</span>
<span className="text-sm text-muted-foreground">
{new Date(subscription.endDate).toLocaleDateString()}
</span>
</div>
<div className="text-sm text-muted-foreground">
+ + + 广
</div>
</div> </div>
) : ( <div className="w-full bg-muted rounded-full h-3">
<div> <div className="bg-brand-600 h-3 rounded-full transition-all" style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }} />
<p className="text-muted-foreground mb-4"></p>
<div className="flex gap-4">
<button
onClick={() => handleSubscribe('MONTHLY')}
disabled={payLoading}
className="px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50"
>
{payLoading ? '处理中...' : '开通月卡 ¥29.9/月'}
</button>
<button
onClick={() => handleSubscribe('YEARLY')}
disabled={payLoading}
className="px-6 py-3 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 disabled:opacity-50"
>
{payLoading ? '处理中...' : '开通年卡 ¥199/年'}
</button>
</div>
</div> </div>
)} </div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{PLANS.map(plan => {
const isCurrent = subscription?.plan === plan.id;
return (
<div key={plan.id} className={`bg-card rounded-2xl border-2 p-6 flex flex-col ${isCurrent ? 'border-brand-600' : plan.popular ? 'border-brand-400' : 'border-border'}`}>
{plan.popular && !isCurrent && (
<span className="self-start text-xs font-medium px-2 py-0.5 bg-brand-600 text-white rounded-full mb-3">{t.member.popular}</span>
)}
{isCurrent && (
<span className="self-start text-xs font-medium px-2 py-0.5 bg-green-600 text-white rounded-full mb-3">{t.member.currentPlan_badge}</span>
)}
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
<div className="mb-4">
{plan.price > 0 ? (
<span className="text-3xl font-bold text-foreground">{plan.price === 29.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{t.member[plan.period]}</span></span>
) : (
<span className="text-2xl font-bold text-foreground">¥0</span>
)}
</div>
<div className="space-y-2 flex-1 mb-6">
{(plan.id === 'FREE' ? FEATURE_LABELS : FEATURE_LABELS).map((f, fi) => (
<div key={f} className="flex items-center gap-2 text-sm">
<span className="text-green-600 text-xs"></span>
<span className="text-muted-foreground">{t.member[f]}</span>
<span className="ml-auto text-xs text-foreground font-medium">{t.member[plan.features[fi]]}</span>
</div>
))}
</div>
{plan.id !== 'FREE' && (
<button onClick={() => handleSubscribe(plan.id)} disabled={payLoading === plan.id || isCurrent}
className={`w-full py-2.5 rounded-xl text-sm font-medium transition-all ${isCurrent ? 'bg-muted text-muted-foreground cursor-default' : plan.popular ? 'bg-brand-600 text-white hover:bg-brand-700' : 'border border-border text-foreground hover:bg-accent'} disabled:opacity-50`}>
{payLoading === plan.id ? t.member.processing : isCurrent ? t.member.currentPlan_badge : t.member.subscribe}
</button>
)}
</div>
);
})}
</div> </div>
<div> <div className="bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4"></h2> <h2 className="text-lg font-semibold text-foreground mb-4">{t.member.orderHistory}</h2>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className="text-muted-foreground text-center py-8"></p> <p className="text-muted-foreground text-center py-8">{t.member.noOrders}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{orders.map(order => ( {orders.map(order => (
<div key={order.id} className="bg-card rounded-xl border border-border p-4 flex items-center justify-between"> <div key={order.id} className="flex items-center justify-between p-4 rounded-xl border border-border">
<div> <div>
<div className="font-medium text-foreground">{order.planType === 'MONTHLY' ? '月卡会员' : '年卡会员'}</div> <div className="font-medium text-foreground">{t.member[order.planType === 'YEARLY' ? 'yearly' : 'monthly']}</div>
<div className="text-sm text-muted-foreground">{new Date(order.createdAt).toLocaleDateString()}</div> <div className="text-sm text-muted-foreground">{new Date(order.createdAt).toLocaleDateString()}</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-semibold text-foreground">¥{order.amount}</div> <div className="font-semibold text-foreground">¥{order.amount}</div>
<span className={`text-xs px-2 py-0.5 rounded ${ <span className={`text-xs px-2 py-0.5 rounded ${order.status === 'PAID' ? 'bg-green-100 text-green-700' : order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : 'bg-muted text-muted-foreground'}`}>
order.status === 'PAID' ? 'bg-green-100 text-green-700' :
order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' :
'bg-muted text-muted-foreground'
}`}>
{order.status} {order.status}
</span> </span>
</div> </div>
+32
View File
@@ -97,6 +97,38 @@ const en: Translations = {
orderHistory: 'Order History', orderHistory: 'Order History',
noOrders: 'No orders yet', noOrders: 'No orders yet',
processing: 'Processing...', processing: 'Processing...',
planFree: 'Free',
planMonthly: 'Monthly',
planYearly: 'Yearly',
priceMonthly: '¥29.9',
priceYearly: '¥199',
perMonth: '/mo',
perYear: '/yr',
popular: 'Most Popular',
featureSandbox: 'AI Sandbox',
featureSandboxFree: '10/day',
featureSandboxPro: '100/day',
featureSandboxUnlimited: 'Unlimited',
featureModels: 'Models',
featureModelsFree: '1 model',
featureModelsPro: '2 models',
featureModelsPremium: 'All models',
featurePrompts: 'Prompts',
featurePromptsFree: 'Basic',
featurePromptsPro: 'All',
featurePromptsPremium: 'All + Exclusive',
featureCourses: 'Courses',
featureCoursesFree: 'Some free',
featureCoursesPro: 'All',
featureCoursesPremium: 'All',
featureAds: 'Ads',
featureAdsFree: 'Yes',
featureAdsPro: 'Ad-free',
featureAdsPremium: 'Ad-free',
dailyQuota: 'Daily Quota Usage',
used: '{n} used',
subscribe: 'Subscribe',
currentPlan_badge: 'Current Plan',
}, },
compare: { compare: {
title: 'Compare Lab', title: 'Compare Lab',
+32
View File
@@ -95,6 +95,38 @@ const zh = {
orderHistory: '订单记录', orderHistory: '订单记录',
noOrders: '暂无订单记录', noOrders: '暂无订单记录',
processing: '处理中...', processing: '处理中...',
planFree: '免费',
planMonthly: '月卡',
planYearly: '年卡',
priceMonthly: '¥29.9',
priceYearly: '¥199',
perMonth: '/月',
perYear: '/年',
popular: '最受欢迎',
featureSandbox: 'AI 沙盒',
featureSandboxFree: '10 次/日',
featureSandboxPro: '100 次/日',
featureSandboxUnlimited: '不限次',
featureModels: '模型选择',
featureModelsFree: '1 个模型',
featureModelsPro: '2 个模型',
featureModelsPremium: '全部模型',
featurePrompts: '提示词库',
featurePromptsFree: '基础',
featurePromptsPro: '全部',
featurePromptsPremium: '全部 + 专属',
featureCourses: '课程学习',
featureCoursesFree: '部分免费',
featureCoursesPro: '全部',
featureCoursesPremium: '全部',
featureAds: '广告',
featureAdsFree: '有广告',
featureAdsPro: '去广告',
featureAdsPremium: '去广告',
dailyQuota: '日配额用量',
used: '已用 {n} 次',
subscribe: '开通',
currentPlan_badge: '当前方案',
}, },
compare: { compare: {
title: '对比实验室', title: '对比实验室',