diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 7cfcec1..6047257 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -19,6 +19,10 @@ export default function DashboardPage() { const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false); const [lastUpdateTime, setLastUpdateTime] = useState(null); const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking'); + + // 支付成功状态 + const [showPaymentSuccess, setShowPaymentSuccess] = useState(false); + const [paymentData, setPaymentData] = useState(null); // 使用 ref 来存储最新的状态,避免定时器闭包问题 const stateRef = useRef({ isUsingMockData, dashboardData }); @@ -31,6 +35,40 @@ export default function DashboardPage() { + // 检测支付成功 + useEffect(() => { + const sessionId = searchParams.get('session_id'); + const payment = searchParams.get('payment'); + + if (sessionId && payment === 'success') { + // 显示支付成功提示 + setShowPaymentSuccess(true); + + // 获取支付详情 + fetchPaymentDetails(sessionId); + + // 清除URL参数,避免刷新页面时重复显示 + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('session_id'); + newUrl.searchParams.delete('payment'); + window.history.replaceState({}, '', newUrl.pathname); + } + }, [searchParams]); + + // 获取支付详情 + const fetchPaymentDetails = async (sessionId: string) => { + try { + const response = await fetch(`/api/payment/checkout-status/${sessionId}?user_id=test_user_123`); + const result = await response.json(); + + if (result.successful && result.data) { + setPaymentData(result.data); + } + } catch (error) { + console.error('获取支付详情失败:', error); + } + }; + // 初始加载数据 const fetchInitialData = async () => { try { @@ -370,6 +408,35 @@ export default function DashboardPage() { return (
+ {/* 支付成功提示 */} + {showPaymentSuccess && paymentData && ( +
+
+
+ + + +
+
+

支付成功!

+

+ 您的订阅已激活,订单号: {paymentData.biz_order_no} +

+
+
+ +
+
+
+ )} + {/* 后台刷新指示器 - 优化用户体验 */} {isBackgroundRefreshing && (
diff --git a/app/payment-success/page.tsx b/app/payment-success/page.tsx new file mode 100644 index 0000000..66963da --- /dev/null +++ b/app/payment-success/page.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { CheckCircle, Loader2, XCircle } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +interface PaymentStatus { + payment_status: 'pending' | 'success' | 'fail'; + biz_order_no: string; + pay_time?: string; + subscription?: { + plan_name: string; + plan_display_name: string; + status: string; + current_period_end?: string; + }; +} + +export default function PaymentSuccessPage() { + const searchParams = useSearchParams(); + const sessionId = searchParams.get('session_id'); + + const [status, setStatus] = useState<'loading' | 'success' | 'failed' | 'timeout'>('loading'); + const [paymentData, setPaymentData] = useState(null); + const [attempts, setAttempts] = useState(0); + + useEffect(() => { + if (!sessionId) { + setStatus('failed'); + return; + } + + const pollPaymentStatus = async () => { + const maxAttempts = 30; // 最多轮询30次 + const interval = 2000; // 每2秒轮询一次 + + for (let i = 0; i < maxAttempts; i++) { + setAttempts(i + 1); + + try { + // 使用新的Checkout Session状态查询 + const { getCheckoutSessionStatus } = await import('@/lib/stripe'); + const result = await getCheckoutSessionStatus(sessionId, 'test_user_123'); // 临时测试用户ID + + if (result.successful && result.data) { + setPaymentData(result.data); + + if (result.data.payment_status === 'success') { + setStatus('success'); + return; + } else if (result.data.payment_status === 'fail') { + setStatus('failed'); + return; + } + } + + // 等待下次轮询 + await new Promise(resolve => setTimeout(resolve, interval)); + } catch (error) { + console.error('轮询Checkout Session状态失败:', error); + } + } + + // 轮询超时 + setStatus('timeout'); + }; + + pollPaymentStatus(); + }, [sessionId]); + + const renderContent = () => { + switch (status) { + case 'loading': + return ( + + +
+ +
+ 处理中... + + 正在确认您的支付,请稍候 +
+ 尝试次数: {attempts}/30 +
+
+ +

+ 请不要关闭此页面,我们正在处理您的订阅 +

+
+
+ ); + + case 'success': + return ( + + +
+ +
+ 支付成功! + + 您的订阅已激活 + +
+ + {paymentData?.subscription && ( +
+

+ {paymentData.subscription.plan_display_name} 套餐 +

+

+ 状态: {paymentData.subscription.status} +

+ {paymentData.subscription.current_period_end && ( +

+ 有效期至: {new Date(paymentData.subscription.current_period_end).toLocaleDateString()} +

+ )} +
+ )} + +
+

订单号: {paymentData?.biz_order_no}

+ {paymentData?.pay_time && ( +

支付时间: {new Date(paymentData.pay_time).toLocaleString()}

+ )} +
+ +
+ + +
+
+
+ ); + + case 'failed': + return ( + + +
+ +
+ 支付失败 + + 很抱歉,您的支付未能成功完成 + +
+ +

+ 请检查您的支付信息或稍后重试 +

+ +
+ + +
+
+
+ ); + + case 'timeout': + return ( + + +
+ +
+ 处理中 + + 支付正在处理中,请稍后查看 + +
+ +

+ 您的支付可能仍在处理中,请稍后检查您的订阅状态 +

+ +
+ + +
+
+
+ ); + + default: + return null; + } + }; + + return ( +
+
+ {renderContent()} +
+
+ ); +} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx new file mode 100644 index 0000000..4a8316d --- /dev/null +++ b/app/pricing/page.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Check, ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { fetchSubscriptionPlans, SubscriptionPlan } from '@/lib/stripe'; + +export default function PricingPage() { + const router = useRouter(); + const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); + const [plans, setPlans] = useState([]); + + // 从后端获取订阅计划数据 + useEffect(() => { + const loadPlans = async () => { + try { + const plansData = await fetchSubscriptionPlans(); + setPlans(plansData); + } catch (err) { + console.error('加载订阅计划失败:', err); + } + }; + + loadPlans(); + }, []); + + // 转换后端数据为前端显示格式,保持原有的数据结构 + const transformPlanForDisplay = (plan: SubscriptionPlan) => { + const monthlyPrice = plan.price_monthly / 100; // 后端存储的是分,转换为元 + const yearlyPrice = plan.price_yearly / 100; + + return { + name: plan.name, + displayName: plan.display_name, + price: { + monthly: monthlyPrice, + yearly: yearlyPrice + }, + description: plan.description, + features: plan.features || [], + popular: plan.is_popular, // 使用后端返回的 is_popular 字段 + buttonText: plan.is_free ? 'Start Free Trial' : 'Subscribe', + buttonVariant: plan.is_free ? 'outline' as const : 'default' as const + }; + }; + + const handleSubscribe = async (planName: string) => { + if (planName === 'hobby') { + return; + } + + try { + // 使用新的Checkout Session方案(更简单!) + const { createCheckoutSession, redirectToCheckout } = await import('@/lib/stripe'); + + // 从localStorage获取当前用户信息 + const User = JSON.parse(localStorage.getItem("currentUser") || "{}"); + + if (!User.id) { + throw new Error("无法获取用户ID,请重新登录"); + } + + // 1. 创建Checkout Session + const result = await createCheckoutSession({ + user_id: String(User.id), + plan_name: planName, + billing_cycle: billingCycle + }); + + if (!result.successful || !result.data) { + throw new Error("create checkout session failed"); + } + + // 2. 直接跳转到Stripe托管页面(就这么简单!) + redirectToCheckout(result.data.checkout_url); + + } catch (error) { + throw new Error("create checkout session failed, please try again later"); + } + }; + + // 如果还没有加载到数据,显示加载状态但保持原有样式 + if (plans.length === 0) { + return ( +
+
+
+ +
+ +
+

Pricing

+

Choose the plan that suits you best

+ +
+ + +
+
+ +
+
+

正在加载订阅计划...

+
+
+
+
+ ); + } + + return ( +
+ {/* Main Content */} +
+ {/* Back Button */} +
+ +
+ +
+

Pricing

+

Choose the plan that suits you best

+ + {/* Billing Toggle */} +
+ + +
+
+ + {/* Plans Section */} +
+
+ {plans.map((plan) => { + const displayPlan = transformPlanForDisplay(plan); + + return ( +
+ {/* 预留标签空间,确保所有卡片组合高度一致 */} +
+ {displayPlan.popular && ( +
+ Most Popular +
+ )} +
+ + + + + {displayPlan.displayName} + +
+
+ {displayPlan.price[billingCycle] === 0 ? ( + 'Free' + ) : ( + <> + ${displayPlan.price[billingCycle]} + + /{billingCycle === 'monthly' ? 'month' : 'year'} + + + )} +
+
+ + + {displayPlan.description} + +
+ + +
+ {displayPlan.features.map((feature, index) => ( +
+
+ +
+ {feature} +
+ ))} +
+ + +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/components/layout/top-bar.tsx b/components/layout/top-bar.tsx index 1023125..c894d5c 100644 --- a/components/layout/top-bar.tsx +++ b/components/layout/top-bar.tsx @@ -73,6 +73,16 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
+ {/* Pricing Link */} + + {/* Notifications */} +
+
+ + )} +
{/* 工具栏-列表形式切换 */}
@@ -188,6 +256,20 @@ export function HomePage2() { />
+ {/* Pricing 入口 */} +
+ +
{ + e.stopPropagation(); + router.push('/pricing'); + }}> + + Pricing +
+
+
+
; + +export interface CreateCheckoutSessionRequest { + user_id: string; + plan_name: string; + billing_cycle: 'monthly' | 'yearly'; +} + +export interface CreateCheckoutSessionData { + checkout_url: string; + session_id: string; + biz_order_no: string; + amount: number; + currency: string; +} + +export type CreateCheckoutSessionResponse = ApiResponse; + +/** + * 获取订阅计划列表 + * 从后端API获取所有活跃的订阅计划,后端已经过滤了活跃计划 + */ +export async function fetchSubscriptionPlans(): Promise { + try { + const response = await get>('/api/subscription/plans'); + + if (!response.successful || !response.data) { + throw new Error(response.message || '获取订阅计划失败'); + } + + // 后端已经过滤了活跃计划,直接按排序顺序排列 + const sortedPlans = response.data.sort((a, b) => a.sort_order - b.sort_order); + + return sortedPlans; + } catch (error) { + console.error('获取订阅计划失败:', error); + throw error; + } +} + +/** + * 创建Checkout Session(推荐方案) + * + * 这是更简单的支付方案: + * 1. 调用此函数获取checkout_url + * 2. 直接跳转到checkout_url + * 3. 用户在Stripe页面完成支付 + * 4. 支付成功后自动跳转回success_url + */ +export async function createCheckoutSession( + request: CreateCheckoutSessionRequest +): Promise { + try { + return await post('/api/payment/checkoutDeepControl', request); + } catch (error) { + console.error('创建Checkout Session失败:', error); + throw error; + } +} + +/** + * 查询Checkout Session状态 + */ +export async function getCheckoutSessionStatus( + sessionId: string, + userId: string +): Promise { + try { + return await get(`/api/payment/checkout-status/${sessionId}?user_id=${userId}`); + } catch (error) { + console.error('查询Checkout Session状态失败:', error); + throw error; + } +} + +/** + * 简单的跳转到Checkout页面的工具函数 + */ +export function redirectToCheckout(checkoutUrl: string) { + if (typeof window !== 'undefined') { + window.location.href = checkoutUrl; + } +} \ No newline at end of file