feat: 落地打赏(个人码捐赠)模式并将付费面改为免费+赞助(无ICP证合规)
- 新增 Donation 模型/迁移/后端 API(GET,POST /donations) - 新增前端 /donate 页:收款码+感谢留言+感谢墙 - 会员页/技能广场/用量包 下架付费,改免费开放+赞助入口 - 沙箱用量耗尽引导至 /donate 赞助 - 导航与页脚新增 赞助/联盟返佣 入口,补充 i18n
This commit is contained in:
@@ -4,240 +4,101 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../lib/auth';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PaymentModal from '@/components/ui/payment-modal';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useT } from '@/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { isWeChatBrowser } from '@/lib/wechat';
|
||||
|
||||
interface PayResult {
|
||||
gatewayOrderId?: string;
|
||||
payUrl?: string;
|
||||
qrcode?: string;
|
||||
codeUrl?: string;
|
||||
redirectUrl?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number; plan: string; startDate: string; endDate: string; status: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: number; orderNo: string; amount: number; planType: string; status: string; payChannel?: string; createdAt: string;
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{ id: 'FREE', nameKey: 'planFree', price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] },
|
||||
{ id: 'MONTHLY', nameKey: 'planMonthly', price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] },
|
||||
{ id: 'YEARLY', nameKey: 'planYearly', price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] },
|
||||
] as const;
|
||||
|
||||
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
|
||||
import { Heart, Link2, Sparkles } from 'lucide-react';
|
||||
|
||||
export default function MemberPage() {
|
||||
const t = useT();
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
||||
const [payChannel, setPayChannel] = useState<'wxpay' | 'alipay'>('alipay');
|
||||
const [paymentModal, setPaymentModal] = useState<{
|
||||
open: boolean; orderNo: string; payResult: PayResult; payChannel: 'wxpay' | 'alipay';
|
||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [subRes, ordersRes, quotaRes] = await Promise.all([
|
||||
apiFetch('/subscriptions/current').catch(() => ({ ok: false })),
|
||||
apiFetch('/orders'),
|
||||
apiFetch('/sandbox/quota'),
|
||||
]);
|
||||
if (subRes.ok) setSubscription(await (subRes as Response).json());
|
||||
const ordersData = await ordersRes.json();
|
||||
setOrders(ordersData.items || []);
|
||||
const quotaData = await quotaRes.json();
|
||||
if (quotaData.remaining !== undefined) setQuota(quotaData);
|
||||
} catch { toast.error("加载失败") }
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleSubscribe(planType: string) {
|
||||
setPayLoading(planType);
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
amount: planType === 'MONTHLY' ? 49.9 : 299,
|
||||
planType,
|
||||
payChannel,
|
||||
};
|
||||
|
||||
const res = await apiFetch('/orders/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.order && data.payResult) {
|
||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
||||
|
||||
if (payUrl || qrCode) {
|
||||
setPaymentModal({
|
||||
open: true,
|
||||
orderNo: data.order.orderNo,
|
||||
payResult: data.payResult,
|
||||
payChannel,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { toast.error("加载失败") }
|
||||
setPayLoading(null);
|
||||
}
|
||||
|
||||
function handlePaymentPaid() {
|
||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
||||
loadData();
|
||||
}
|
||||
useEffect(() => {
|
||||
apiFetch('/sandbox/quota')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.remaining !== undefined) setQuota(data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64 mb-8" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{[1,2,3].map(i => <Skeleton key={i} className="h-72 rounded-2xl" />)}
|
||||
</div>
|
||||
<Skeleton className="h-40 rounded-2xl mb-6" />
|
||||
<Skeleton className="h-48 rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">← 返回我的</Link>
|
||||
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">← {t.myLearning.back}</Link>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.member.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t.member.desc}</p>
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
|
||||
<Card className="p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-foreground">{t.member.dailyQuota}</span>
|
||||
<span className="text-sm text-muted-foreground">{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-3">
|
||||
<div className="bg-brand-600 h-3 rounded-full transition-all" style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }} />
|
||||
<div
|
||||
className="bg-brand-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<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 === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{plan.price > 0 ? t.member[plan.period as keyof typeof t.member] : ''}</span></span>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-foreground">¥0</span>
|
||||
)}
|
||||
<Card className="p-8 mb-6 bg-gradient-to-br from-brand-50 to-white dark:from-brand-950/20 dark:to-background text-center">
|
||||
<div className="inline-flex w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-2xl items-center justify-center mb-4">
|
||||
<Sparkles className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground">{t.member.freeUser}</h2>
|
||||
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.member.benefits}</p>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link href="/donate" className="block">
|
||||
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center">
|
||||
<Heart className="w-5 h-5 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 mb-6">
|
||||
{(plan.id === 'FREE' ? FEATURE_LABELS : FEATURE_LABELS).map((f, fi) => (
|
||||
<div key={f} className="grid grid-cols-[1fr_auto] gap-x-2 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-green-600 text-xs shrink-0">✓</span>
|
||||
<span className="text-muted-foreground truncate">{t.member[f]}</span>
|
||||
</div>
|
||||
<span className="text-xs text-foreground font-medium text-right">{t.member[plan.features[fi]]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{plan.id !== 'FREE' && (
|
||||
<div className="space-y-3">
|
||||
{!isCurrent && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPayChannel('alipay'); handleSubscribe(plan.id); }}
|
||||
disabled={payLoading === plan.id}
|
||||
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
plan.popular
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'border border-border text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{payLoading === plan.id ? t.member.processing : t.member.alipay}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPayChannel('wxpay'); handleSubscribe(plan.id); }}
|
||||
disabled={payLoading === plan.id}
|
||||
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
plan.popular
|
||||
? 'bg-brand-600 text-white hover:bg-brand-700'
|
||||
: 'border border-border text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{payLoading === plan.id ? t.member.processing : t.member.wechatPay}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<button disabled
|
||||
className="w-full py-2.5 rounded-xl text-sm font-medium bg-muted text-muted-foreground cursor-default">
|
||||
{t.member.currentPlan_badge}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-semibold text-foreground">{t.donate.supportUs}</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-sm text-muted-foreground flex-1">{t.donate.supportDesc}</p>
|
||||
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
|
||||
{t.donate.toDonate} →
|
||||
</span>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/affiliate" className="block">
|
||||
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center">
|
||||
<Link2 className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">{t.donate.affiliate}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground flex-1">{t.affiliate.desc}</p>
|
||||
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
|
||||
{t.affiliate.title} →
|
||||
</span>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">{t.member.orderHistory}</h2>
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">{t.member.noOrders}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orders.map(order => (
|
||||
<div key={order.id} className="flex items-center justify-between p-4 rounded-xl border border-border">
|
||||
<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>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-foreground">¥{order.amount}</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{order.payChannel && (
|
||||
<span className="text-xs text-muted-foreground">{order.payChannel === 'alipay' ? '支付宝' : '微信'}</span>
|
||||
)}
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PaymentModal
|
||||
open={paymentModal.open}
|
||||
orderNo={paymentModal.orderNo}
|
||||
payResult={paymentModal.payResult}
|
||||
payChannel={paymentModal.payChannel}
|
||||
onPaid={handlePaymentPaid}
|
||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
||||
/>
|
||||
<p className="mt-8 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||
{t.donate.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user