diff --git a/api/errorHandle.ts b/api/errorHandle.ts index 3adaafd..6fdac06 100644 --- a/api/errorHandle.ts +++ b/api/errorHandle.ts @@ -16,22 +16,52 @@ const HTTP_ERROR_MESSAGES: Record = { 503: "Service temporarily unavailable, please try again later.", 504: "Gateway timeout, please try again later.", }; + /** * 默认错误提示信息 */ -const DEFAULT_ERROR_MESSAGE = - "Please try again if the network is abnormal. If it happens again, please contact us."; +const DEFAULT_ERROR_MESSAGE = "网络异常,请重试。如果问题持续存在,请联系我们。"; /** - * 根据错误码显示对应的提示信息 + * 特殊错误码的处理函数 + */ +const ERROR_HANDLERS: Record void> = { + 401: () => { + // 清除本地存储的 token + localStorage.removeItem('token'); + // 跳转到登录页面 + window.location.href = '/login'; + }, + 403: () => { + // 显示积分不足通知 + import('../utils/notifications').then(({ showInsufficientPointsNotification }) => { + showInsufficientPointsNotification(); + }); + } +}; + +/** + * 根据错误码显示对应的提示信息并执行相应处理 * @param code - HTTP错误码 * @param customMessage - 自定义错误信息(可选) */ export const errorHandle = debounce( (code: number, customMessage?: string): void => { - const errorMessage = + const errorMessage = customMessage || HTTP_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGE; - message.error(errorMessage); + + // 显示错误提示 + message.error({ + content: errorMessage, + duration: 3, + className: 'custom-error-message' + }); + + // 执行特殊错误码的处理函数 + const handler = ERROR_HANDLERS[code]; + if (handler) { + handler(); + } }, 100 ); diff --git a/api/request.ts b/api/request.ts index 4f86d78..d04ed06 100644 --- a/api/request.ts +++ b/api/request.ts @@ -2,6 +2,25 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosR import { message } from "antd"; import { BASE_URL } from './constants' import { errorHandle } from './errorHandle'; + +/** + * 统一的错误处理函数 + * @param error - 错误对象 + * @param defaultMessage - 默认错误信息 + */ +const handleRequestError = (error: any, defaultMessage: string = '请求失败') => { + if (error.response) { + const { status, data } = error.response; + const errorMessage = data?.message || defaultMessage; + errorHandle(status, errorMessage); + } else if (error.request) { + // 请求已发出但没有收到响应 + errorHandle(0, '网络请求失败,请检查网络连接'); + } else { + // 请求配置出错 + errorHandle(0, error.message || defaultMessage); + } +}; // 创建 axios 实例 const request: AxiosInstance = axios.create({ baseURL: BASE_URL, // 设置基础URL @@ -29,23 +48,32 @@ request.interceptors.request.use( // 响应拦截器 request.interceptors.response.use( (response: AxiosResponse) => { - // 直接返回响应数据 - if (response.data?.code !=0) { - // TODO 暂时固定报错信息,后续根据后端返回的错误码进行处理 - errorHandle(0); + // 检查业务状态码 + if (response.data?.code !== 0) { + // 处理业务层面的错误 + const businessCode = response.data?.code; + const errorMessage = response.data?.message; + + // 特殊处理 401 和 403 业务状态码 + if (businessCode === 401) { + errorHandle(401, errorMessage); + return Promise.reject(new Error(errorMessage)); + } + + if (businessCode === 403) { + errorHandle(403, errorMessage); + return Promise.reject(new Error(errorMessage)); + } + + // 其他业务错误 + errorHandle(0, errorMessage); + return Promise.reject(new Error(errorMessage)); } + return response.data; }, (error) => { - if (error.response) { - errorHandle(error.response.status); - } else if (error.request) { - // 请求已发出但没有收到响应 - errorHandle(0); - } else { - // 检修 - console.error(error); - } + handleRequestError(error); return Promise.reject(error); } ); @@ -76,7 +104,7 @@ export async function streamJsonPost( onJson: (json: T) => void ) { try { - const token = localStorage?.getItem('token') || 'mock-token'; + const token = localStorage?.getItem('token') || ''; const response = await fetch(`${BASE_URL}${url}`, { method: 'POST', headers: { @@ -86,8 +114,23 @@ export async function streamJsonPost( body: JSON.stringify(body), }); + // 处理 HTTP 错误状态 if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const error = { + response: { + status: response.status, + data: { message: await response.text().then(text => { + try { + const data = JSON.parse(text); + return data.message || `HTTP error! status: ${response.status}`; + } catch { + return `HTTP error! status: ${response.status}`; + } + })} + } + }; + handleRequestError(error); + throw error; } if (!response.body) { @@ -207,7 +250,8 @@ export const stream = async ({ const response = await request(config); onComplete?.(); return response; - } catch (error) { + } catch (error: any) { + handleRequestError(error, '流式请求失败'); onError?.(error); throw error; } @@ -239,8 +283,9 @@ export const downloadStream = async ( window.URL.revokeObjectURL(downloadUrl); return response; - } catch (error) { + } catch (error: any) { console.error('文件下载失败:', error); + handleRequestError(error, '文件下载失败'); throw error; } }; diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 7cfcec1..036c12f 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,41 @@ 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 User = JSON.parse(localStorage.getItem("currentUser") || "{}"); + const response = await fetch(`/api/payment/checkout-status/${sessionId}?user_id=${User.id}`); + const result = await response.json(); + + if (result.successful && result.data) { + setPaymentData(result.data); + } + } catch (error) { + console.error('获取支付详情失败:', error); + } + }; + // 初始加载数据 const fetchInitialData = async () => { try { @@ -370,6 +409,35 @@ export default function DashboardPage() { return (
+ {/* 支付成功提示 */} + {showPaymentSuccess && paymentData && ( +
+
+
+ + + +
+
+

支付成功!

+

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

+
+
+ +
+
+
+ )} + {/* 后台刷新指示器 - 优化用户体验 */} {isBackgroundRefreshing && (
diff --git a/app/globals.css b/app/globals.css index 6916fe7..a37fc43 100644 --- a/app/globals.css +++ b/app/globals.css @@ -239,3 +239,7 @@ body { .animate-fade-in { animation: fade-in 0.2s ease-out forwards; } + +.ant-notification-notice-wrapper { + background: transparent !important; +} \ No newline at end of file diff --git a/app/payment-success/page.tsx b/app/payment-success/page.tsx new file mode 100644 index 0000000..589d424 --- /dev/null +++ b/app/payment-success/page.tsx @@ -0,0 +1,237 @@ +'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 User = JSON.parse(localStorage.getItem("currentUser") || "{}"); + const result = await getCheckoutSessionStatus(sessionId, User.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..7f61a3a --- /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<'month' | 'year'>('month'); + 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_month / 100; // 后端存储的是分,转换为元 + const yearlyPrice = plan.price_year / 100; + + return { + name: plan.name, + displayName: plan.display_name, + price: { + month: monthlyPrice, + year: 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 === 'month' ? 'month' : 'year'} + + + )} +
+
+ + + {displayPlan.description} + +
+ + +
+ {displayPlan.features.map((feature, index) => ( +
+
+ +
+ {feature} +
+ ))} +
+ + +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/components/SmartChatBox/MessageRenderer.tsx b/components/SmartChatBox/MessageRenderer.tsx index f158c7c..510b509 100644 --- a/components/SmartChatBox/MessageRenderer.tsx +++ b/components/SmartChatBox/MessageRenderer.tsx @@ -124,6 +124,8 @@ export function MessageRenderer({ msg }: MessageRendererProps) { ); case "progress": return ; + case "link": + return {b.text}; default: return null; } diff --git a/components/SmartChatBox/SmartChatBox.tsx b/components/SmartChatBox/SmartChatBox.tsx index a9fae09..5c05de1 100644 --- a/components/SmartChatBox/SmartChatBox.tsx +++ b/components/SmartChatBox/SmartChatBox.tsx @@ -133,8 +133,8 @@ export default function SmartChatBox({ Chat {/* System push toggle */} { blocks.push({ @@ -382,12 +186,12 @@ function transformSystemMessage( }); blocks.push({ type: 'text', - text: '后续可在剪辑线上进行编辑。' + text: 'You can edit the video on the editing line later.' }, { type: 'progress', value: customData.completed_count, total: customData.total_count, - label: `已完成 ${customData.completed_count} 个分镜,共有 ${customData.total_count} 个分镜` + label: `Completed ${customData.completed_count} shots, total ${customData.total_count} shots` }, { type: 'text', text: `\n${content}` @@ -404,7 +208,7 @@ function transformSystemMessage( */ function transformMessage(apiMessage: RealApiMessage): ChatMessage { try { - const { id, role, content, created_at, function_name, custom_data, status, intent_type } = apiMessage; + const { id, role, content, created_at, function_name, custom_data, status, intent_type, error_message } = apiMessage; let message: ChatMessage = { id: id ? id.toString() : Date.now().toString(), role: role, @@ -414,46 +218,52 @@ function transformMessage(apiMessage: RealApiMessage): ChatMessage { status: status || 'success', }; - if (role === 'assistant' || role === 'user') { - try { - const contentObj = JSON.parse(content); - const contentArray = Array.isArray(contentObj) ? contentObj : [contentObj]; - contentArray.forEach((c: ApiMessageContent) => { - if (c.type === "text") { - message.blocks.push({ type: "text", text: c.content }); - } else if (c.type === "image") { - message.blocks.push({ type: "image", url: c.content }); - } else if (c.type === "video") { - message.blocks.push({ type: "video", url: c.content }); - } else if (c.type === "audio") { - message.blocks.push({ type: "audio", url: c.content }); - } - }); - } catch (error) { - // 如果 JSON 解析失败,将整个 content 作为文本内容 + if (error_message && error_message === 'no enough credits') { + message.blocks = NoEnoughCreditsMessageBlocks; + } else { + if (role === 'assistant' || role === 'user') { + try { + const contentObj = JSON.parse(content); + const contentArray = Array.isArray(contentObj) ? contentObj : [contentObj]; + contentArray.forEach((c: ApiMessageContent) => { + if (c.type === "text") { + message.blocks.push({ type: "text", text: c.content }); + } else if (c.type === "image") { + message.blocks.push({ type: "image", url: c.content }); + } else if (c.type === "video") { + message.blocks.push({ type: "video", url: c.content }); + } else if (c.type === "audio") { + message.blocks.push({ type: "audio", url: c.content }); + } else if (c.type === "link") { + message.blocks.push({ type: "link", text: c.content, url: c.url || '' }); + } + }); + } catch (error) { + // 如果 JSON 解析失败,将整个 content 作为文本内容 + message.blocks.push({ type: "text", text: content }); + } + } else if (role === 'system' && function_name && custom_data) { + // 处理系统消息 + message.blocks = transformSystemMessage(function_name, content, custom_data); + } else { message.blocks.push({ type: "text", text: content }); } - } else if (role === 'system' && function_name && custom_data) { - // 处理系统消息 - message.blocks = transformSystemMessage(function_name, content, custom_data); - } else { - message.blocks.push({ type: "text", text: content }); } // 如果没有有效的 blocks,至少添加一个文本块 if (message.blocks.length === 0) { - message.blocks.push({ type: "text", text: "无内容" }); + message.blocks.push({ type: "text", text: "No content" }); } return message; } catch (error) { - console.error("转换消息格式失败:", error, apiMessage); + console.error("Failed to transform message format:", error, apiMessage); // 返回一个带有错误信息的消息 return { id: new Date().getTime().toString(), role: apiMessage.role, createdAt: new Date(apiMessage.created_at).getTime(), - blocks: [{ type: "text", text: "消息格式错误" }], + blocks: [{ type: "text", text: "Message format error" }], chatType: 'chat', status: 'error', }; @@ -479,13 +289,13 @@ export async function fetchMessages( }; try { - console.log('发送历史消息请求:', request); + console.log('Send history message request:', request); const response = await post>("/intelligent/history", request); - console.log('收到历史消息响应:', response); + console.log('Receive history message response:', response); // 确保 response.data 和 messages 存在 if (!response.data || !response.data.messages) { - console.error('历史消息响应格式错误:', response); + console.error('History message response format error:', response); return { messages: [], hasMore: false, @@ -494,13 +304,13 @@ export async function fetchMessages( } // 转换消息并按时间排序 - // if (response.data.messages.length === 0) { - // return { - // messages: MOCK_MESSAGES.map(transformMessage), - // hasMore: false, - // totalCount: 0 - // }; - // } + if (response.data.messages.length === 0) { + return { + messages: EMPTY_MESSAGES.map(transformMessage), + hasMore: false, + totalCount: 0 + }; + } return { messages: response.data.messages .map(transformMessage) @@ -509,7 +319,7 @@ export async function fetchMessages( totalCount: response.data.total_count }; } catch (error) { - console.error("获取消息历史失败:", error); + console.error("Failed to get message history:", error); throw error; } } @@ -550,10 +360,10 @@ export async function sendMessage( } try { - console.log('发送消息请求:', request); + console.log('Send message request:', request); await post>("/intelligent/chat", request); } catch (error) { - console.error("发送消息失败:", error); + console.error("Send message failed:", error); throw error; } } @@ -567,5 +377,5 @@ export async function retryMessage( ): Promise { // TODO: 实现实际的重试逻辑,可能需要保存原始消息内容 // 这里简单重用发送消息的接口 - return sendMessage([{ type: "text", text: "重试消息" }], config); + return sendMessage([{ type: "text", text: "Retry message" }], config); } \ No newline at end of file diff --git a/components/SmartChatBox/types.ts b/components/SmartChatBox/types.ts index 66a2fbb..3587c56 100644 --- a/components/SmartChatBox/types.ts +++ b/components/SmartChatBox/types.ts @@ -7,7 +7,8 @@ export type MessageBlock = | { type: "image"; url: string; alt?: string } | { type: "video"; url: string; poster?: string } | { type: "audio"; url: string } - | { type: "progress"; value: number; total?: number; label?: string }; + | { type: "progress"; value: number; total?: number; label?: string } + | { type: "link"; text: string; url: string }; export interface ChatMessage { id: string; @@ -127,6 +128,7 @@ export interface ShotVideoGeneration { export interface ApiMessageContent { type: ContentType; content: string; + url?: string; } export interface RealApiMessage { @@ -138,4 +140,5 @@ export interface RealApiMessage { custom_data?: ProjectInit | ScriptSummary | CharacterGeneration | SketchGeneration | ShotSketchGeneration | ShotVideoGeneration; status: MessageStatus; intent_type: IntentType; + error_message?: string; } \ No newline at end of file diff --git a/components/layout/top-bar.tsx b/components/layout/top-bar.tsx index 0098303..023990c 100644 --- a/components/layout/top-bar.tsx +++ b/components/layout/top-bar.tsx @@ -1,38 +1,38 @@ "use client"; -import "../pages/style/top-bar.css"; -import { Button } from "@/components/ui/button"; -import { GradientText } from "@/components/ui/gradient-text"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { useTheme } from "next-themes"; +import '../pages/style/top-bar.css'; +import { Button } from '@/components/ui/button'; +import { GradientText } from '@/components/ui/gradient-text'; +import { useTheme } from 'next-themes'; import { Sun, Moon, User, - Settings, + Sparkles, LogOut, - Bell, PanelsLeftBottom, -} from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +} from 'lucide-react'; +import { motion } from 'framer-motion'; +import ReactDOM from 'react-dom'; +import { useRouter } from 'next/navigation'; +import React, { useRef, useEffect, useLayoutEffect, useState } from 'react'; +import { logoutUser } from '@/lib/auth'; -export function TopBar({ - collapsed, - onToggleSidebar, -}: { - collapsed: boolean; - onToggleSidebar: () => void; -}) { - const { theme, setTheme } = useTheme(); + +interface User { + id: string; + name: string; + email: string; + avatar: string; +} + +export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onToggleSidebar: () => void }) { const router = useRouter(); - const pathname = usePathname(); + const [isOpen, setIsOpen] = React.useState(false); + const menuRef = useRef(null); + const buttonRef = useRef(null); + const currentUser: User = JSON.parse(localStorage.getItem('currentUser') || '{}'); + const [mounted, setMounted] = React.useState(false); const [isLogin, setIsLogin] = useState(false); useEffect(() => { const currentUser = localStorage.getItem("currentUser"); @@ -41,7 +41,52 @@ export function TopBar({ } else { setIsLogin(false); } - }, [pathname]); + }); + useLayoutEffect(() => { + console.log('Setting mounted state'); + setMounted(true); + return () => console.log('Cleanup mounted effect'); + }, []); + + + // 处理点击事件 + useEffect(() => { + if (!isOpen) return; + + let isClickStartedInside = false; + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as Node; + isClickStartedInside = !!( + menuRef.current?.contains(target) || + buttonRef.current?.contains(target) + ); + }; + + const handleMouseUp = (event: MouseEvent) => { + const target = event.target as Node; + const isClickEndedInside = !!( + menuRef.current?.contains(target) || + buttonRef.current?.contains(target) + ); + + // 只有当点击开始和结束都在外部时才关闭 + if (!isClickStartedInside && !isClickEndedInside) { + setIsOpen(false); + } + isClickStartedInside = false; + }; + + // 在冒泡阶段处理事件 + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isOpen]); + const handleAnimationEnd = (event: React.AnimationEvent) => { const element = event.currentTarget; element.classList.remove("on"); @@ -53,8 +98,8 @@ export function TopBar({ }; return ( -
-
+
+
{isLogin && (
- {isLogin ? ( -
- {/* Notifications */} - +
+ {/* Pricing Link */} + + + {/* Notifications */} + {/* */} {/* Theme Toggle */} {/* */} - {/* User Menu */} - - - - - - - - Settings - - - - - Log out - - - -
- ) : ( -
-
router.push("/login")} - data-alt="login-button" - style={{ pointerEvents: 'auto' }} + {/* User Menu */} +
+
-
router.push("/signup")} - data-alt="signup-button" - style={{ pointerEvents: 'auto' }} - > - Sign up -
+ + + + {mounted && isOpen ? ReactDOM.createPortal( + e.stopPropagation()} + > + {/* User Info */} +
+
+
+ {currentUser.name ? currentUser.name.charAt(0) : ''} +
+
+

{currentUser.name}

+

{currentUser.email}

+
+
{ + logoutUser(); + }} + title="退出登录" + > + +
+
+
+ + {/* AI Points */} +
+
+ + 100 credits +
+ +
+ + {/* Menu Items */} +
+ {/* router.push('/my-library')} + data-alt="my-library-button" + > + + My Library + + + { + // 处理退出登录 + setIsOpen(false); + }} + data-alt="logout-button" + > + + Logout + */} + + {/* Footer */} +
+
Privacy Policy · Terms of Service
+
250819215404 | 2025/8/20 06:00:50
+
+
+
+ , document.body) + : null}
- )} +
+
); } diff --git a/components/pages/login.tsx b/components/pages/login.tsx index 2d806d4..2f58b39 100644 --- a/components/pages/login.tsx +++ b/components/pages/login.tsx @@ -81,6 +81,10 @@ export default function Login() { endPercentage={70} /> + {/* beta标签 */} + + Beta +
diff --git a/components/ui/FloatingGlassPanel.tsx b/components/ui/FloatingGlassPanel.tsx index 588efcf..f8470bc 100644 --- a/components/ui/FloatingGlassPanel.tsx +++ b/components/ui/FloatingGlassPanel.tsx @@ -11,9 +11,10 @@ type FloatingGlassPanelProps = { width?: string; r_key?: string | number; panel_style?: React.CSSProperties; + className?: string; }; -export default function FloatingGlassPanel({ open, onClose, children, width = '320px', r_key, panel_style, clickMaskClose = true }: FloatingGlassPanelProps) { +export default function FloatingGlassPanel({ open, onClose, children, width = '320px', r_key, panel_style, clickMaskClose = true, className }: FloatingGlassPanelProps) { // 定义弹出动画 const bounceAnimation = { scale: [0.95, 1.02, 0.98, 1], @@ -23,7 +24,7 @@ export default function FloatingGlassPanel({ open, onClose, children, width = '3 return ( {open && ( -
+
=> { - const token = getToken(); - - // 构建请求头 - const headers: Record = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - ...(options.headers as Record), - }; - - // 添加token到请求头(如果存在) - if (token) { - headers['X-EASE-ADMIN-TOKEN'] = token; - } - - try { - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers, - }); - - // 检查响应状态 - if (!response.ok) { - if (response.status === 401) { - // Token过期或无效 - clearAuthData(); - window.location.href = '/login'; - throw new Error('身份验证失败,请重新登录'); - } - throw new Error(`请求失败: ${response.status}`); - } - - const data = await response.json(); - - // 检查业务状态码 - if (data.code === '401' || data.status === 401) { - clearAuthData(); - window.location.href = '/login'; - throw new Error('身份验证失败,请重新登录'); - } - - return data; - } catch (error) { - console.error('API request failed:', error); - throw error; - } -}; - -/** - * GET请求 - */ -export const apiGet = (endpoint: string, options: RequestInit = {}) => { - return apiRequest(endpoint, { - ...options, - method: 'GET', - }); -}; - -/** - * POST请求 - */ -export const apiPost = (endpoint: string, data?: any, options: RequestInit = {}) => { - return apiRequest(endpoint, { - ...options, - method: 'POST', - body: data ? JSON.stringify(data) : undefined, - }); -}; - -/** - * PUT请求 - */ -export const apiPut = (endpoint: string, data?: any, options: RequestInit = {}) => { - return apiRequest(endpoint, { - ...options, - method: 'PUT', - body: data ? JSON.stringify(data) : undefined, - }); -}; - -/** - * DELETE请求 - */ -export const apiDelete = (endpoint: string, options: RequestInit = {}) => { - return apiRequest(endpoint, { - ...options, - method: 'DELETE', - }); -}; - -/** - * 文件上传请求 - */ -export const apiUpload = async (endpoint: string, formData: FormData, options: RequestInit = {}) => { - const token = getToken(); - - // 构建请求头(文件上传时不设置Content-Type,让浏览器自动设置) - const headers: Record = { - 'Accept': 'application/json', - ...(options.headers as Record), - }; - - // 添加token到请求头(如果存在) - if (token) { - headers['X-EASE-ADMIN-TOKEN'] = token; - } - - try { - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - method: 'POST', - headers, - body: formData, - }); - - // 检查响应状态 - if (!response.ok) { - if (response.status === 401) { - // Token过期或无效 - clearAuthData(); - window.location.href = '/login'; - throw new Error('身份验证失败,请重新登录'); - } - throw new Error(`请求失败: ${response.status}`); - } - - const data = await response.json(); - - // 检查业务状态码 - if (data.code === '401' || data.status === 401) { - clearAuthData(); - window.location.href = '/login'; - throw new Error('身份验证失败,请重新登录'); - } - - return data; - } catch (error) { - console.error('API request failed:', error); - throw error; - } -}; \ No newline at end of file diff --git a/lib/stripe.ts b/lib/stripe.ts new file mode 100644 index 0000000..49e883f --- /dev/null +++ b/lib/stripe.ts @@ -0,0 +1,114 @@ +/** + * Stripe 支付相关工具函数 + */ +import { post, get } from '@/api/request'; +import { ApiResponse } from '@/api/common'; + +export interface SubscriptionPlan { + id: number; + name: string; + display_name: string; + description: string; + price_month: number; + price_year: number; + features: string[]; + is_free: boolean; + is_popular: boolean; + sort_order: number; +} + +export interface PaymentStatusData { + 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 type PaymentStatusResponse = ApiResponse; + +export interface CreateCheckoutSessionRequest { + user_id: string; + plan_name: string; + billing_cycle: 'month' | 'year'; +} + +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 diff --git a/utils/notifications.tsx b/utils/notifications.tsx new file mode 100644 index 0000000..a516df2 --- /dev/null +++ b/utils/notifications.tsx @@ -0,0 +1,108 @@ +import { notification } from 'antd'; +import { useRouter } from 'next/router'; + +type NotificationType = 'success' | 'info' | 'warning' | 'error'; + +const darkGlassStyle = { + background: 'rgba(30, 32, 40, 0.95)', + backdropFilter: 'blur(10px)', + WebkitBackdropFilter: 'blur(10px)', + border: '1px solid rgba(255, 255, 255, 0.08)', + borderRadius: '8px', + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)', + padding: '12px 16px', +}; + +const messageStyle = { + fontSize: '13px', + fontWeight: 500, + color: '#ffffff', + marginBottom: '6px', + display: 'flex', + alignItems: 'center', + gap: '6px', +}; + +const iconStyle = { + color: '#F6B266', // 警告图标颜色 + background: 'rgba(246, 178, 102, 0.15)', + padding: '4px', + borderRadius: '6px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}; + +const descriptionStyle = { + fontSize: '12px', + color: 'rgba(255, 255, 255, 0.65)', + marginBottom: '12px', + lineHeight: '1.5', +}; + +const btnStyle = { + color: 'rgb(250 173 20 / 90%)', + background: 'transparent', + border: 'none', + cursor: 'pointer', + padding: 0, + fontSize: '12px', + fontWeight: 500, + textDecoration: 'underline', + textUnderlineOffset: '2px', + textDecorationColor: 'rgb(250 173 20 / 60%)', + transition: 'all 0.2s ease', +}; + +/** + * 显示积分不足通知 + * @description 在右上角显示一个带有充值链接的积分不足提醒 + */ +export const showInsufficientPointsNotification = () => { + notification.warning({ + message: null, + description: ( +
+

+ Insufficient credits reminder +

+

Your credits are insufficient, please upgrade to continue.

+ +
+ ), + duration: 5, + placement: 'topRight', + style: darkGlassStyle, + className: 'dark-glass-notification', + closeIcon: ( + + ), + }); +}; + +/** + * 全局配置通知样式 + */ +notification.config({ + maxCount: 3, // 最多同时显示3个通知 +});