mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
feat(auth): 添加订阅管理功能,包括订阅计划和订阅购买
This commit is contained in:
parent
375a302d45
commit
b0bae22ad0
@ -1,6 +1,7 @@
|
||||
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface SubscriptionDialogProps {
|
||||
isOpen: boolean;
|
||||
@ -8,50 +9,78 @@ interface SubscriptionDialogProps {
|
||||
}
|
||||
|
||||
interface SubscriptionPlan {
|
||||
_id: number;
|
||||
name: string;
|
||||
tokens: number;
|
||||
price: number;
|
||||
description: string;
|
||||
savePercentage?: number;
|
||||
save_percentage?: number;
|
||||
}
|
||||
|
||||
const subscriptionPlans: SubscriptionPlan[] = [
|
||||
{
|
||||
name: "专业版",
|
||||
tokens: 10000000,
|
||||
price: 20,
|
||||
description: "适合业余爱好者和轻度用户进行探索性使用。"
|
||||
},
|
||||
{
|
||||
name: "专业版 50",
|
||||
tokens: 26000000,
|
||||
price: 50,
|
||||
description: "为每周需要使用多八多几次的专业人士设计。",
|
||||
savePercentage: 3
|
||||
},
|
||||
{
|
||||
name: "专业版 100",
|
||||
tokens: 55000000,
|
||||
price: 100,
|
||||
description: "适合希望提升日常工作流程的重度用户。",
|
||||
savePercentage: 9
|
||||
},
|
||||
{
|
||||
name: "专业版 200",
|
||||
tokens: 120000000,
|
||||
price: 200,
|
||||
description: "最适合将多八多作为核心工具持续使用的超级用户。",
|
||||
savePercentage: 17
|
||||
}
|
||||
];
|
||||
interface UserSubscription {
|
||||
plan: SubscriptionPlan;
|
||||
tokensLeft: number;
|
||||
nextReloadDate: string;
|
||||
}
|
||||
|
||||
export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps) {
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [userSubscription, setUserSubscription] = useState<UserSubscription | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) return null;
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
fetchSubscriptionData();
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
const currentPlan = subscriptionPlans[1]; // 假设当前用户使用的是"专业版 50"
|
||||
const fetchSubscriptionData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [plansResponse, userSubResponse] = await Promise.all([
|
||||
fetch('/api/subscription-plans'),
|
||||
fetch('/api/user-subscription')
|
||||
]);
|
||||
const plans = await plansResponse.json();
|
||||
const userSub = await userSubResponse.json();
|
||||
setSubscriptionPlans(plans);
|
||||
setUserSubscription(userSub);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscription data:', error);
|
||||
toast.error('获取订阅信息失败,请稍后重试。');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async (planId: number) => {
|
||||
try {
|
||||
const response = await fetch('/api/purchase-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
planId,
|
||||
billingCycle,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
toast.success('订阅购买成功!');
|
||||
fetchSubscriptionData(); // 刷新订阅信息
|
||||
} else {
|
||||
toast.error(result.message || '购买失败,请稍后重试。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error purchasing subscription:', error);
|
||||
toast.error('购买过程中出现错误,请稍后重试。');
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || isLoading) return null;
|
||||
|
||||
return (
|
||||
<DialogRoot open={isOpen}>
|
||||
@ -65,23 +94,27 @@ export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-bolt-elements-textPrimary font-bold">300万</span>
|
||||
<span className="text-bolt-elements-textSecondary"> 代币剩余。</span>
|
||||
<span className="text-bolt-elements-textSecondary">2600万代币将在17天后添加。</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-bolt-elements-textSecondary">需要更多代币?</span>
|
||||
<br />
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
升级您的计划或购买
|
||||
<a href="#" className="text-bolt-elements-item-contentAccent hover:underline">代币充值包</a>
|
||||
</span>
|
||||
{userSubscription && (
|
||||
<div className="bg-bolt-elements-background-depth-2 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-bolt-elements-textPrimary font-bold">{userSubscription.tokensLeft.toLocaleString()}</span>
|
||||
<span className="text-bolt-elements-textSecondary"> 代币剩余。</span>
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
{userSubscription.plan.tokens.toLocaleString()}代币将在{new Date(userSubscription.nextReloadDate).toLocaleDateString()}后添加。
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-bolt-elements-textSecondary">需要更多代币?</span>
|
||||
<br />
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
升级您的计划或购买
|
||||
<a href="#" className="text-bolt-elements-item-contentAccent hover:underline">代币充值包</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center space-x-4">
|
||||
<button
|
||||
@ -108,12 +141,12 @@ export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps)
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{subscriptionPlans.map((plan) => (
|
||||
<div key={plan.name} className={`bg-bolt-elements-background-depth-2 p-4 rounded-lg ${plan.name === currentPlan.name ? 'border-2 border-bolt-elements-item-contentAccent' : ''}`}>
|
||||
<div key={plan._id} className={`bg-bolt-elements-background-depth-2 p-4 rounded-lg ${plan._id === userSubscription?.plan._id ? 'border-2 border-bolt-elements-item-contentAccent' : ''}`}>
|
||||
<h3 className="text-bolt-elements-textPrimary font-bold text-lg">{plan.name}</h3>
|
||||
<div className="text-bolt-elements-textSecondary mb-2">
|
||||
{(plan.tokens / 1000000).toFixed(0)}M 代币
|
||||
{plan.savePercentage && (
|
||||
<span className="ml-2 text-green-500">节省 {plan.savePercentage}%</span>
|
||||
{plan.save_percentage && (
|
||||
<span className="ml-2 text-green-500">节省 {plan.save_percentage}%</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-bolt-elements-textTertiary text-sm mb-4">{plan.description}</p>
|
||||
@ -121,13 +154,14 @@ export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps)
|
||||
¥{plan.price * (billingCycle === 'yearly' ? 10 : 1)}/{billingCycle === 'yearly' ? '年' : '月'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePurchase(plan._id)}
|
||||
className={`w-full py-2 rounded-md ${
|
||||
plan.name === currentPlan.name
|
||||
plan._id === userSubscription?.plan._id
|
||||
? 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
|
||||
: 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
|
||||
}`}
|
||||
>
|
||||
{plan.name === currentPlan.name ? '管理当前计划' : `升级到${plan.name}`}
|
||||
{plan._id === userSubscription?.plan._id ? '管理当前计划' : `升级到${plan.name}`}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -9,12 +9,14 @@ import { LoginDialog } from '~/components/auth/LoginDialog';
|
||||
import { RegisterDialog } from '~/components/auth/RegisterDialog';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { SubscriptionDialog } from '~/components/auth/SubscriptionDialog';
|
||||
|
||||
export function Header() {
|
||||
const chat = useStore(chatStore);
|
||||
const [isLoginOpen, setIsLoginOpen] = useState(false);
|
||||
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [isSubscriptionOpen, setIsSubscriptionOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header
|
||||
@ -63,10 +65,19 @@ export function Header() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={() => setIsSubscriptionOpen(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-bolt-elements-button-secondary-text bg-bolt-elements-button-secondary-background hover:bg-bolt-elements-button-secondary-backgroundHover rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background"
|
||||
>
|
||||
订阅管理
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LoginDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
|
||||
<RegisterDialog isOpen={isRegisterOpen} onClose={() => setIsRegisterOpen(false)} />
|
||||
<SubscriptionDialog isOpen={isSubscriptionOpen} onClose={() => setIsSubscriptionOpen(false)} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
42
app/routes/api.purchase-subscription.ts
Normal file
42
app/routes/api.purchase-subscription.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { db } from '~/lib/db.server';
|
||||
import { requireUserId } from '~/lib/session.server';
|
||||
|
||||
export async function action({ request }) {
|
||||
const userId = await requireUserId(request);
|
||||
const { planId, billingCycle } = await request.json();
|
||||
|
||||
try {
|
||||
// 开始数据库事务
|
||||
await db.transaction(async (trx) => {
|
||||
// 获取订阅计划详情
|
||||
const plan = await trx('subscription_plans').where('_id', planId).first();
|
||||
if (!plan) {
|
||||
throw new Error('Invalid subscription plan');
|
||||
}
|
||||
|
||||
// 计算实际价格和代币数量
|
||||
const price = billingCycle === 'yearly' ? plan.price * 10 : plan.price;
|
||||
const tokens = billingCycle === 'yearly' ? plan.tokens * 12 : plan.tokens;
|
||||
|
||||
// 创建交易记录
|
||||
await trx('user_transactions').insert({
|
||||
user_id: userId,
|
||||
type: 'subscription',
|
||||
plan_id: planId,
|
||||
amount: price,
|
||||
tokens: tokens,
|
||||
status: 'completed', // 假设支付已完成
|
||||
payment_method: 'credit_card', // 假设使用信用卡支付
|
||||
transaction_id: `sub_${Date.now()}`, // 生成一个简单的交易ID
|
||||
});
|
||||
|
||||
// 这里可以添加更多逻辑,如更新用户的订阅状态等
|
||||
});
|
||||
|
||||
return json({ success: true, message: '订阅购买成功' });
|
||||
} catch (error) {
|
||||
console.error('Error purchasing subscription:', error);
|
||||
return json({ error: 'Failed to purchase subscription' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
app/routes/api.subscription-plans.ts
Normal file
12
app/routes/api.subscription-plans.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { db } from '~/lib/db.server';
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
const plans = await db.select().from('subscription_plans').where('is_active', true);
|
||||
return json(plans);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscription plans:', error);
|
||||
return json({ error: 'Failed to fetch subscription plans' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
24
app/routes/api.user-subscription.ts
Normal file
24
app/routes/api.user-subscription.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { db } from '~/lib/db.server';
|
||||
import { requireUserId } from '~/lib/session.server';
|
||||
|
||||
export async function loader({ request }) {
|
||||
const userId = await requireUserId(request);
|
||||
try {
|
||||
const userSubscription = await db.select(
|
||||
'subscription_plans.*',
|
||||
'user_transactions.tokens as tokensLeft',
|
||||
db.raw('DATE_ADD(user_transactions._create, INTERVAL 1 MONTH) as nextReloadDate')
|
||||
)
|
||||
.from('user_transactions')
|
||||
.join('subscription_plans', 'user_transactions.plan_id', 'subscription_plans._id')
|
||||
.where('user_transactions.user_id', userId)
|
||||
.orderBy('user_transactions._create', 'desc')
|
||||
.first();
|
||||
|
||||
return json(userSubscription);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user subscription:', error);
|
||||
return json({ error: 'Failed to fetch user subscription' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user