feat: P0d 会员定价页重构 — 用量可视化 + 方案对比
- 三栏定价卡片(免费/月卡/年卡),参考 opencode Go 页面风格 - 日配额用量进度条 - 功能对比表(沙盒/模型/提示词/课程/广告) - 当前方案标记 + 最受欢迎标记 - useT 全量翻译 - 补充中/英 30+ 定价相关翻译 key
This commit is contained in:
@@ -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">← 返回我的</Link>
|
||||||
← 返回我的
|
<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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '对比实验室',
|
||||||
|
|||||||
Reference in New Issue
Block a user