forked from 77media/video-flow
Merge branch 'dev' of https://git.qikongjian.com/77media/video-flow into dev
This commit is contained in:
commit
03f822dba3
@ -16,14 +16,32 @@ const HTTP_ERROR_MESSAGES: Record<number, string> = {
|
||||
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<number, () => void> = {
|
||||
401: () => {
|
||||
// 清除本地存储的 token
|
||||
localStorage.removeItem('token');
|
||||
// 跳转到登录页面
|
||||
window.location.href = '/login';
|
||||
},
|
||||
403: () => {
|
||||
// 显示积分不足通知
|
||||
import('../utils/notifications').then(({ showInsufficientPointsNotification }) => {
|
||||
showInsufficientPointsNotification();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据错误码显示对应的提示信息并执行相应处理
|
||||
* @param code - HTTP错误码
|
||||
* @param customMessage - 自定义错误信息(可选)
|
||||
*/
|
||||
@ -31,7 +49,19 @@ export const errorHandle = debounce(
|
||||
(code: number, customMessage?: string): void => {
|
||||
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
|
||||
);
|
||||
|
||||
@ -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<T = any>(
|
||||
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<T = any>(
|
||||
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 <T>({
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -20,6 +20,10 @@ export default function DashboardPage() {
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking');
|
||||
|
||||
// 支付成功状态
|
||||
const [showPaymentSuccess, setShowPaymentSuccess] = useState(false);
|
||||
const [paymentData, setPaymentData] = useState<any>(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 (
|
||||
<div className="h-full bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex flex-col overflow-hidden">
|
||||
{/* 支付成功提示 */}
|
||||
{showPaymentSuccess && paymentData && (
|
||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg max-w-md">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-green-800">支付成功!</h3>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
您的订阅已激活,订单号: {paymentData.biz_order_no}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={() => setShowPaymentSuccess(false)}
|
||||
className="text-green-400 hover:text-green-600"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 后台刷新指示器 - 优化用户体验 */}
|
||||
{isBackgroundRefreshing && (
|
||||
<div className="fixed top-4 right-4 z-50 bg-blue-500/90 backdrop-blur-sm text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 max-w-xs">
|
||||
|
||||
@ -239,3 +239,7 @@ body {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.ant-notification-notice-wrapper {
|
||||
background: transparent !important;
|
||||
}
|
||||
237
app/payment-success/page.tsx
Normal file
237
app/payment-success/page.tsx
Normal file
@ -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<PaymentStatus | null>(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 (
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Loader2 className="w-16 h-16 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
<CardTitle>处理中...</CardTitle>
|
||||
<CardDescription>
|
||||
正在确认您的支付,请稍候
|
||||
<br />
|
||||
尝试次数: {attempts}/30
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
请不要关闭此页面,我们正在处理您的订阅
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
</div>
|
||||
<CardTitle className="text-green-600">支付成功!</CardTitle>
|
||||
<CardDescription>
|
||||
您的订阅已激活
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{paymentData?.subscription && (
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-green-800">
|
||||
{paymentData.subscription.plan_display_name} 套餐
|
||||
</h3>
|
||||
<p className="text-sm text-green-600">
|
||||
状态: {paymentData.subscription.status}
|
||||
</p>
|
||||
{paymentData.subscription.current_period_end && (
|
||||
<p className="text-sm text-green-600">
|
||||
有效期至: {new Date(paymentData.subscription.current_period_end).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>订单号: {paymentData?.biz_order_no}</p>
|
||||
{paymentData?.pay_time && (
|
||||
<p>支付时间: {new Date(paymentData.pay_time).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => window.location.href = '/dashboard'}
|
||||
className="flex-1"
|
||||
>
|
||||
前往控制台
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/'}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
case 'failed':
|
||||
return (
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<XCircle className="w-16 h-16 text-red-500" />
|
||||
</div>
|
||||
<CardTitle className="text-red-600">支付失败</CardTitle>
|
||||
<CardDescription>
|
||||
很抱歉,您的支付未能成功完成
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
请检查您的支付信息或稍后重试
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => window.location.href = '/pricing'}
|
||||
className="flex-1"
|
||||
>
|
||||
重新选择套餐
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/'}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
case 'timeout':
|
||||
return (
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Loader2 className="w-16 h-16 text-yellow-500" />
|
||||
</div>
|
||||
<CardTitle className="text-yellow-600">处理中</CardTitle>
|
||||
<CardDescription>
|
||||
支付正在处理中,请稍后查看
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
您的支付可能仍在处理中,请稍后检查您的订阅状态
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1"
|
||||
>
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/dashboard'}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
查看订阅状态
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-lg">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
app/pricing/page.tsx
Normal file
282
app/pricing/page.tsx
Normal file
@ -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<SubscriptionPlan[]>([]);
|
||||
|
||||
// 从后端获取订阅计划数据
|
||||
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 (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<main className="container mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-6xl font-bold mb-6">Pricing</h1>
|
||||
<p className="text-xl text-gray-400 mb-8">Choose the plan that suits you best</p>
|
||||
|
||||
<div className="inline-flex items-center bg-gray-900/50 rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setBillingCycle('year')}
|
||||
className={`flex items-center justify-center px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||
billingCycle === 'year'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
|
||||
: 'text-gray-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pay Yearly
|
||||
<span className={`ml-2 text-sm ${
|
||||
billingCycle === 'year'
|
||||
? 'text-white/90'
|
||||
: 'text-gray-400'
|
||||
}`}>
|
||||
Up to 20% off 🔥
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('month')}
|
||||
className={`px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||
billingCycle === 'month'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
|
||||
: 'text-gray-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pay Monthly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="text-center text-gray-400">
|
||||
<p>正在加载订阅计划...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-6 py-16">
|
||||
{/* Back Button */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-6xl font-bold mb-6">Pricing</h1>
|
||||
<p className="text-xl text-gray-400 mb-8">Choose the plan that suits you best</p>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="inline-flex items-center bg-gray-900/50 rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setBillingCycle('year')}
|
||||
className={`flex items-center justify-center px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||
billingCycle === 'year'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
|
||||
: 'text-gray-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pay Yearly
|
||||
<span className={`ml-2 text-sm ${
|
||||
billingCycle === 'year'
|
||||
? 'text-white/90'
|
||||
: 'text-gray-400'
|
||||
}`}>
|
||||
Up to 20% off 🔥
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('month')}
|
||||
className={`px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||
billingCycle === 'month'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
|
||||
: 'text-gray-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pay Monthly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plans Section */}
|
||||
<div className="mb-16">
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||
{plans.map((plan) => {
|
||||
const displayPlan = transformPlanForDisplay(plan);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`flex flex-col h-full transition-all duration-300 ${
|
||||
displayPlan.popular ? 'hover:scale-105 hover:shadow-2xl' : ''
|
||||
}`}
|
||||
style={{ minHeight: '540px' }}
|
||||
>
|
||||
{/* 预留标签空间,确保所有卡片组合高度一致 */}
|
||||
<div className="h-10 flex items-center justify-center">
|
||||
{displayPlan.popular && (
|
||||
<div className="bg-gradient-to-r from-pink-500 to-purple-600 text-white py-2 text-center text-sm font-medium rounded-t-2xl w-full shadow-lg">
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
className={`bg-gray-900/50 ${displayPlan.popular ? 'border-l border-r border-b border-gray-700/50 rounded-b-2xl rounded-t-none' : 'border border-gray-700/50 rounded-2xl'} overflow-hidden ${displayPlan.popular ? '' : 'transition-all duration-300 hover:scale-105 hover:shadow-2xl'} flex flex-col flex-grow ${
|
||||
displayPlan.popular ? 'ring-2 ring-pink-500/50 shadow-pink-500/20' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
<CardHeader className="pt-8 pb-6 flex-shrink-0 text-center">
|
||||
<CardTitle className="text-white text-2xl font-bold mb-4">{displayPlan.displayName}</CardTitle>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="text-4xl font-bold text-white">
|
||||
{displayPlan.price[billingCycle] === 0 ? (
|
||||
'Free'
|
||||
) : (
|
||||
<>
|
||||
<span className="text-5xl">${displayPlan.price[billingCycle]}</span>
|
||||
<span className="text-lg text-gray-400 font-normal">
|
||||
/{billingCycle === 'month' ? 'month' : 'year'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardDescription className="text-gray-300 text-base px-4">
|
||||
{displayPlan.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-8 pb-10 flex-grow flex flex-col justify-between">
|
||||
<div className="space-y-5">
|
||||
{displayPlan.features.map((feature, index) => (
|
||||
<div key={index} className="flex items-start space-x-3">
|
||||
<div className="w-5 h-5 bg-green-500/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Check className="w-3 h-3 text-green-400" />
|
||||
</div>
|
||||
<span className="text-gray-300 text-sm leading-relaxed">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSubscribe(displayPlan.name)}
|
||||
variant={displayPlan.buttonVariant}
|
||||
className={`w-full py-4 rounded-xl font-medium text-base transition-all duration-300 mt-6 ${
|
||||
displayPlan.buttonText === 'Start Free Trial'
|
||||
? 'border-2 border-gray-600 text-white hover:border-pink-500 hover:text-pink-400 hover:bg-gray-800/50'
|
||||
: 'bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700 shadow-lg hover:shadow-xl'
|
||||
}`}
|
||||
>
|
||||
{displayPlan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -124,6 +124,8 @@ export function MessageRenderer({ msg }: MessageRendererProps) {
|
||||
);
|
||||
case "progress":
|
||||
return <ProgressBar key={idx} value={b.value} total={b.total} label={b.label} />;
|
||||
case "link":
|
||||
return <a key={idx} href={b.url} className="underline">{b.text}</a>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -133,8 +133,8 @@ export default function SmartChatBox({
|
||||
<span>Chat</span>
|
||||
{/* System push toggle */}
|
||||
<Switch
|
||||
checkedChildren="系统推送:开"
|
||||
unCheckedChildren="系统推送:关"
|
||||
checkedChildren="System push: On"
|
||||
unCheckedChildren="System push: Off"
|
||||
checked={systemPush}
|
||||
onChange={toggleSystemPush}
|
||||
className="ml-2"
|
||||
|
||||
@ -18,225 +18,16 @@ import {
|
||||
} from "./types";
|
||||
import { post } from "@/api/request";
|
||||
|
||||
// Mock 数据
|
||||
const MOCK_MESSAGES: RealApiMessage[] = [
|
||||
// 用户发送剧本
|
||||
// 空消息 默认展示
|
||||
const EMPTY_MESSAGES: RealApiMessage[] = [
|
||||
{
|
||||
id: 1,
|
||||
role: 'user',
|
||||
content: JSON.stringify([{
|
||||
type: 'text',
|
||||
content: '我想拍一个关于一个小女孩和她的机器人朋友的故事,故事发生在未来世界。'
|
||||
}]),
|
||||
created_at: '2024-03-20T10:00:00Z',
|
||||
function_name: undefined,
|
||||
custom_data: undefined,
|
||||
status: 'success',
|
||||
intent_type: 'chat'
|
||||
},
|
||||
// 项目初始化
|
||||
{
|
||||
id: 2,
|
||||
role: 'system',
|
||||
content: '我会帮您创建一个温馨感人的科幻短片,讲述人工智能与人类情感的故事。',
|
||||
created_at: '2024-03-20T10:00:10Z',
|
||||
function_name: 'create_project',
|
||||
custom_data: {
|
||||
project_data: {
|
||||
script: '小女孩和机器人朋友的故事'
|
||||
}
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 剧本总结
|
||||
{
|
||||
id: 3,
|
||||
role: 'system',
|
||||
content: '故事概要:在2045年的未来城市,10岁的小女孩艾米丽收到了一个特别的生日礼物——一个具有高度情感智能的机器人伙伴"小星"。随着时间推移,他们建立了深厚的友谊。当小星因能源耗尽即将永久关闭时,艾米丽想尽办法寻找解决方案,最终通过她的坚持和创意,成功为小星找到了新的能源,让这段跨越人机界限的友谊得以延续。',
|
||||
created_at: '2024-03-20T10:01:00Z',
|
||||
function_name: 'generate_script_summary',
|
||||
custom_data: {
|
||||
summary: '一个关于友谊和希望的温暖故事'
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 角色生成 - 艾米丽
|
||||
{
|
||||
id: 4,
|
||||
role: 'system',
|
||||
content: '主角艾米丽的形象已生成',
|
||||
created_at: '2024-03-20T10:02:00Z',
|
||||
function_name: 'generate_character',
|
||||
custom_data: {
|
||||
character_name: '艾米丽',
|
||||
image_path: 'https://picsum.photos/seed/emily/300/400',
|
||||
completed_count: 1,
|
||||
total_count: 2
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 角色生成 - 小星
|
||||
{
|
||||
id: 5,
|
||||
role: 'system',
|
||||
content: '机器人小星的形象已生成',
|
||||
created_at: '2024-03-20T10:03:00Z',
|
||||
function_name: 'generate_character',
|
||||
custom_data: {
|
||||
character_name: '小星',
|
||||
image_path: 'https://picsum.photos/seed/robot/300/400',
|
||||
completed_count: 2,
|
||||
total_count: 2
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 场景生成 - 未来城市
|
||||
{
|
||||
id: 6,
|
||||
role: 'system',
|
||||
content: '未来城市场景设计完成',
|
||||
created_at: '2024-03-20T10:04:00Z',
|
||||
function_name: 'generate_sketch',
|
||||
custom_data: {
|
||||
sketch_name: '未来城市街景',
|
||||
image_path: 'https://picsum.photos/seed/city/600/400',
|
||||
completed_count: 1,
|
||||
total_count: 3
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 场景生成 - 艾米丽的房间
|
||||
{
|
||||
id: 7,
|
||||
role: 'system',
|
||||
content: '艾米丽的未来风格卧室设计完成',
|
||||
created_at: '2024-03-20T10:05:00Z',
|
||||
function_name: 'generate_sketch',
|
||||
custom_data: {
|
||||
sketch_name: '艾米丽的卧室',
|
||||
image_path: 'https://picsum.photos/seed/room/600/400',
|
||||
completed_count: 2,
|
||||
total_count: 3
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 场景生成 - 实验室
|
||||
{
|
||||
id: 8,
|
||||
role: 'system',
|
||||
content: '高科技实验室场景设计完成',
|
||||
created_at: '2024-03-20T10:06:00Z',
|
||||
function_name: 'generate_sketch',
|
||||
custom_data: {
|
||||
sketch_name: '未来实验室',
|
||||
image_path: 'https://picsum.photos/seed/lab/600/400',
|
||||
completed_count: 3,
|
||||
total_count: 3
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 分镜生成 - 相遇
|
||||
{
|
||||
id: 9,
|
||||
role: 'system',
|
||||
content: '第一个分镜:艾米丽收到礼物时的场景',
|
||||
created_at: '2024-03-20T10:07:00Z',
|
||||
function_name: 'generate_shot_sketch',
|
||||
custom_data: {
|
||||
shot_type: '中景',
|
||||
atmosphere: '温馨、期待',
|
||||
key_action: '艾米丽惊喜地打开礼物盒,小星缓缓启动',
|
||||
url: 'https://picsum.photos/seed/shot1/600/400',
|
||||
completed_count: 1,
|
||||
total_count: 3
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 分镜生成 - 危机
|
||||
{
|
||||
id: 10,
|
||||
role: 'system',
|
||||
content: '第二个分镜:小星能源耗尽的场景',
|
||||
created_at: '2024-03-20T10:08:00Z',
|
||||
function_name: 'generate_shot_sketch',
|
||||
custom_data: {
|
||||
shot_type: '特写',
|
||||
atmosphere: '紧张、担忧',
|
||||
key_action: '小星的能源指示灯闪烁微弱,艾米丽神情焦急',
|
||||
url: 'https://picsum.photos/seed/shot2/600/400',
|
||||
completed_count: 2,
|
||||
total_count: 3
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 分镜生成 - 解决
|
||||
{
|
||||
id: 11,
|
||||
role: 'system',
|
||||
content: '第三个分镜:找到新能源解决方案的场景',
|
||||
created_at: '2024-03-20T10:09:00Z',
|
||||
function_name: 'generate_shot_sketch',
|
||||
custom_data: {
|
||||
shot_type: '全景',
|
||||
atmosphere: '欢欣、胜利',
|
||||
key_action: '实验室中艾米丽成功激活新能源,小星重新焕发活力',
|
||||
url: 'https://picsum.photos/seed/shot3/600/400',
|
||||
completed_count: 3,
|
||||
total_count: 3
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 分镜视频生成
|
||||
{
|
||||
id: 11.1,
|
||||
role: 'system',
|
||||
content: '分镜视频生成完成',
|
||||
created_at: '2024-03-20T10:10:00Z',
|
||||
function_name: 'generate_video',
|
||||
custom_data: {
|
||||
prompt_json: {
|
||||
core_atmosphere: '欢欣、胜利',
|
||||
},
|
||||
urls: ['https://cdn.qikongjian.com/faces/1755798635_facefusion_output_1755798635.mp4'],
|
||||
completed_count: 1,
|
||||
total_count: 1
|
||||
},
|
||||
status: 'success',
|
||||
intent_type: 'procedure'
|
||||
},
|
||||
// 用户反馈
|
||||
{
|
||||
id: 12,
|
||||
role: 'user',
|
||||
content: JSON.stringify([{
|
||||
type: 'text',
|
||||
content: '这个故事设计太棒了!特别喜欢艾米丽和小星的互动场景。'
|
||||
}]),
|
||||
created_at: '2024-03-20T10:10:00Z',
|
||||
function_name: undefined,
|
||||
custom_data: undefined,
|
||||
status: 'success',
|
||||
intent_type: 'function_call'
|
||||
},
|
||||
// 助手回复
|
||||
{
|
||||
id: 13,
|
||||
role: 'assistant',
|
||||
content: JSON.stringify([{
|
||||
type: 'text',
|
||||
content: '谢谢您的肯定!我们可以继续优化任何场景或角色设计,您觉得有什么地方需要调整吗?'
|
||||
content: '🌟Welcome to MovieFlow 🎬✨\nTell me your idea~💡\nI am your AI assistant🤖, I can help you:\n🎭 Generate actor images\n📽️ Generate scene & shot sketches\n🎞️ Complete video creation\n\nLet\'s start our creative journey together!❤️'
|
||||
}]),
|
||||
created_at: '2024-03-20T10:10:10Z',
|
||||
created_at: new Date().toISOString(),
|
||||
function_name: undefined,
|
||||
custom_data: undefined,
|
||||
status: 'success',
|
||||
@ -244,6 +35,19 @@ const MOCK_MESSAGES: RealApiMessage[] = [
|
||||
}
|
||||
];
|
||||
|
||||
// 用户积分不足消息
|
||||
const NoEnoughCreditsMessageBlocks: MessageBlock[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Insufficient credits.'
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
text: 'Upgrade to continue.',
|
||||
url: '/pricing'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* 类型守卫函数
|
||||
*/
|
||||
@ -286,7 +90,7 @@ function transformSystemMessage(
|
||||
if (isProjectInit(customData)) {
|
||||
blocks = [{
|
||||
type: 'text',
|
||||
text: `🎬 根据您输入的 "${customData.project_data.script}",我已完成项目的初始化。\n${content}`
|
||||
text: `🎬 According to your input "${customData.project_data.script}", I have completed the initialization of the project.\n${content}`
|
||||
}];
|
||||
}
|
||||
break;
|
||||
@ -294,7 +98,7 @@ function transformSystemMessage(
|
||||
case 'generate_script_summary':
|
||||
if (isScriptSummary(customData)) {
|
||||
blocks = [
|
||||
{ type: 'text', text: `🎬 剧本摘要生成完成\n\n${customData.summary}\n\n${content}` }
|
||||
{ type: 'text', text: `🎬 I have completed the script summary generation.\n\n${customData.summary}\n\n${content}` }
|
||||
];
|
||||
}
|
||||
break;
|
||||
@ -303,18 +107,18 @@ function transformSystemMessage(
|
||||
if (isCharacterGeneration(customData)) {
|
||||
blocks = [{
|
||||
type: 'text',
|
||||
text: `🎭 演员 "${customData.character_name}" 已就位`
|
||||
text: `🎭 Actor "${customData.character_name}" is ready.`
|
||||
}, {
|
||||
type: 'image',
|
||||
url: customData.image_path
|
||||
}, {
|
||||
type: 'text',
|
||||
text: '图片中演员形象仅供参考,后续可根据视频生成后进行调整。'
|
||||
text: 'The actor image is for reference only, and can be adjusted after the video is generated.'
|
||||
}, {
|
||||
type: 'progress',
|
||||
value: customData.completed_count,
|
||||
total: customData.total_count,
|
||||
label: `已完成 ${customData.completed_count} 个演员,共有 ${customData.total_count} 个`
|
||||
label: `Completed ${customData.completed_count} actors, total ${customData.total_count} actors`
|
||||
}, {
|
||||
type: 'text',
|
||||
text: `\n${content}`
|
||||
@ -326,18 +130,18 @@ function transformSystemMessage(
|
||||
if (isSketchGeneration(customData)) {
|
||||
blocks = [{
|
||||
type: 'text',
|
||||
text: `🎨 场景 "${customData.sketch_name}" 参考图片已生成 \n`
|
||||
text: `🎨 Scene "${customData.sketch_name}" reference image generated \n`
|
||||
}, {
|
||||
type: 'image',
|
||||
url: customData.image_path
|
||||
}, {
|
||||
type: 'text',
|
||||
text: '图片中场景仅供参考,后续可根据视频生成后进行调整。'
|
||||
text: 'The scene image is for reference only, and can be adjusted after the video is generated.'
|
||||
}, {
|
||||
type: 'progress',
|
||||
value: customData.completed_count,
|
||||
total: customData.total_count,
|
||||
label: `已完成 ${customData.completed_count} 个场景,共有 ${customData.total_count} 个`
|
||||
label: `Completed ${customData.completed_count} scenes, total ${customData.total_count} scenes`
|
||||
}, {
|
||||
type: 'text',
|
||||
text: `\n${content}`
|
||||
@ -349,18 +153,18 @@ function transformSystemMessage(
|
||||
if (isShotSketchGeneration(customData)) {
|
||||
blocks = [{
|
||||
type: 'text',
|
||||
text: `🎬 故事板静帧生成 \n镜头类型:${customData.shot_type}\n氛围:${customData.atmosphere}\n关键动作:${customData.key_action}`
|
||||
text: `🎬 Storyboard static frame generation \nShot type: ${customData.shot_type}\nAtmosphere: ${customData.atmosphere}\nKey action: ${customData.key_action}`
|
||||
}, {
|
||||
type: 'image',
|
||||
url: customData.url
|
||||
}, {
|
||||
type: 'text',
|
||||
text: '图片中故事板静帧仅供参考,后续可根据视频生成后进行调整。'
|
||||
text: 'The storyboard static frame image is for reference only, and can be adjusted after the video is generated.'
|
||||
}, {
|
||||
type: 'progress',
|
||||
value: customData.completed_count,
|
||||
total: customData.total_count,
|
||||
label: `已完成 ${customData.completed_count} 个故事板静帧,共有 ${customData.total_count} 个`
|
||||
label: `Completed ${customData.completed_count} storyboard static frames, total ${customData.total_count} storyboard static frames`
|
||||
}, {
|
||||
type: 'text',
|
||||
text: `\n${content}`
|
||||
@ -372,7 +176,7 @@ function transformSystemMessage(
|
||||
if (isShotVideoGeneration(customData)) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: `🎬 该分镜下包含${customData.urls.length} 个视频。 \n核心氛围:${customData.prompt_json.core_atmosphere}`
|
||||
text: `🎬 There are ${customData.urls.length} videos in this shot. \nCore atmosphere: ${customData.prompt_json.core_atmosphere}`
|
||||
});
|
||||
customData.urls.forEach((url: string) => {
|
||||
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,6 +218,9 @@ function transformMessage(apiMessage: RealApiMessage): ChatMessage {
|
||||
status: status || 'success',
|
||||
};
|
||||
|
||||
if (error_message && error_message === 'no enough credits') {
|
||||
message.blocks = NoEnoughCreditsMessageBlocks;
|
||||
} else {
|
||||
if (role === 'assistant' || role === 'user') {
|
||||
try {
|
||||
const contentObj = JSON.parse(content);
|
||||
@ -427,6 +234,8 @@ function transformMessage(apiMessage: RealApiMessage): ChatMessage {
|
||||
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) {
|
||||
@ -439,21 +248,22 @@ function transformMessage(apiMessage: RealApiMessage): ChatMessage {
|
||||
} 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<ApiResponse<MessagesResponse>>("/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<ApiResponse<RealApiMessage>>("/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<void> {
|
||||
// TODO: 实现实际的重试逻辑,可能需要保存原始消息内容
|
||||
// 这里简单重用发送消息的接口
|
||||
return sendMessage([{ type: "text", text: "重试消息" }], config);
|
||||
return sendMessage([{ type: "text", text: "Retry message" }], config);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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<HTMLDivElement>) => {
|
||||
const element = event.currentTarget;
|
||||
element.classList.remove("on");
|
||||
@ -53,8 +98,8 @@ export function TopBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed right-0 top-0 left-0 h-16 header z-50">
|
||||
<div className="h-full flex items-center justify-between pr-6 pl-2">
|
||||
<div className="fixed right-0 top-0 left-0 h-16 header z-[999]" style={{ isolation: 'isolate' }}>
|
||||
<div className="h-full flex items-center justify-between pr-6 pl-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{isLogin && (
|
||||
<Button
|
||||
@ -92,16 +137,31 @@ export function TopBar({
|
||||
</h1>
|
||||
</span>
|
||||
</span>
|
||||
{/* beta标签 */}
|
||||
<div className="relative transform translate-y-[-1px]">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold tracking-wider text-[rgb(212 202 202)] border border-[rgba(106,244,249,0.2)] rounded-full shadow-[0_0_10px_rgba(106,244,249,0.1)]">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLogin ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<Button variant="ghost" size="sm">
|
||||
<Bell className="h-4 w-4" />
|
||||
{/* Pricing Link */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push('/pricing')}
|
||||
className="text-gray-300 hover:text-white"
|
||||
>
|
||||
Pricing
|
||||
</Button>
|
||||
|
||||
{/* Notifications */}
|
||||
{/* <Button variant="ghost" size="sm" onClick={() => setOpenModal(true)}>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button> */}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
@ -113,47 +173,114 @@ export function TopBar({
|
||||
</Button> */}
|
||||
|
||||
{/* User Menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<div className="relative" style={{ isolation: 'isolate' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log('Button clicked, current isOpen:', isOpen);
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
data-alt="user-menu-trigger"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
<span className="ml-2">User</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-4" style={{ pointerEvents: 'auto' }}>
|
||||
<div
|
||||
className="flex justify-center items-center text-white text-base w-[100px] h-[40px] cursor-pointer event-on hover:opacity-80 transition-opacity"
|
||||
onClick={() => router.push("/login")}
|
||||
data-alt="login-button"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
|
||||
{mounted && isOpen ? ReactDOM.createPortal(
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '4rem',
|
||||
right: '1rem',
|
||||
width: '18rem',
|
||||
zIndex: 9999
|
||||
}}
|
||||
className="bg-[#1E1E1E] rounded-lg shadow-lg overflow-hidden"
|
||||
data-alt="user-menu-dropdown"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Login
|
||||
{/* User Info */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-10 w-10 rounded-full bg-[#1E4D3E] flex items-center justify-center text-white font-semibold">
|
||||
{currentUser.name ? currentUser.name.charAt(0) : ''}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className="text-sm font-medium">{currentUser.name}</p>
|
||||
<p className="text-xs text-gray-500">{currentUser.email}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-center items-center text-black text-base bg-white rounded-full w-[100px] h-[40px] cursor-pointer event-on hover:opacity-80 transition-opacity"
|
||||
onClick={() => router.push("/signup")}
|
||||
data-alt="signup-button"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
className='cursor-pointer hover:text-red-400 transition-colors duration-200'
|
||||
onClick={() => {
|
||||
logoutUser();
|
||||
}}
|
||||
title="退出登录"
|
||||
>
|
||||
Sign up
|
||||
<LogOut className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Points */}
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span className="text-white underline text-sm">100 credits</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-white border-white hover:bg-white/10 rounded-full px-8"
|
||||
onClick={() => router.push('/pricing')}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{/* <motion.button
|
||||
whileHover={{ backgroundColor: 'rgba(255,255,255,0.1)' }}
|
||||
className="w-full flex items-center space-x-2 px-3 py-2 rounded-md text-sm text-white"
|
||||
onClick={() => router.push('/my-library')}
|
||||
data-alt="my-library-button"
|
||||
>
|
||||
<Library className="h-4 w-4" />
|
||||
<span>My Library</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ backgroundColor: 'rgba(255,255,255,0.1)' }}
|
||||
className="w-full flex items-center space-x-2 px-3 py-2 rounded-md text-sm text-white"
|
||||
onClick={() => {
|
||||
// 处理退出登录
|
||||
setIsOpen(false);
|
||||
}}
|
||||
data-alt="logout-button"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</motion.button> */}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 px-3 py-2 text-xs text-gray-400 text-center">
|
||||
<div>Privacy Policy · Terms of Service</div>
|
||||
<div>250819215404 | 2025/8/20 06:00:50</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
, document.body)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -81,6 +81,10 @@ export default function Login() {
|
||||
endPercentage={70}
|
||||
/>
|
||||
</span>
|
||||
{/* beta标签 */}
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 text-[8px] font-semibold tracking-wider text-[rgb(212 202 202)] border border-[rgba(106,244,249,0.2)] rounded-full shadow-[0_0_10px_rgba(106,244,249,0.1)]">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="left-panel">
|
||||
|
||||
@ -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 (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className={`fixed inset-0 z-50 flex items-center justify-center ${className}`}>
|
||||
<motion.div
|
||||
key={r_key}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
|
||||
152
lib/api.ts
152
lib/api.ts
@ -1,152 +0,0 @@
|
||||
import { getToken, clearAuthData } from './auth';
|
||||
|
||||
// API基础URL
|
||||
const API_BASE_URL = 'https://77.smartvideo.py.qikongjian.com';
|
||||
|
||||
/**
|
||||
* 统一的API请求方法
|
||||
*/
|
||||
export const apiRequest = async (
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<any> => {
|
||||
const token = getToken();
|
||||
|
||||
// 构建请求头
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// 添加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<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// 添加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;
|
||||
}
|
||||
};
|
||||
114
lib/stripe.ts
Normal file
114
lib/stripe.ts
Normal file
@ -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<PaymentStatusData>;
|
||||
|
||||
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<CreateCheckoutSessionData>;
|
||||
|
||||
/**
|
||||
* 获取订阅计划列表
|
||||
* 从后端API获取所有活跃的订阅计划,后端已经过滤了活跃计划
|
||||
*/
|
||||
export async function fetchSubscriptionPlans(): Promise<SubscriptionPlan[]> {
|
||||
try {
|
||||
const response = await get<ApiResponse<SubscriptionPlan[]>>('/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<CreateCheckoutSessionResponse> {
|
||||
try {
|
||||
return await post<CreateCheckoutSessionResponse>('/api/payment/checkoutDeepControl', request);
|
||||
} catch (error) {
|
||||
console.error('创建Checkout Session失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询Checkout Session状态
|
||||
*/
|
||||
export async function getCheckoutSessionStatus(
|
||||
sessionId: string,
|
||||
userId: string
|
||||
): Promise<PaymentStatusResponse> {
|
||||
try {
|
||||
return await get<PaymentStatusResponse>(`/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;
|
||||
}
|
||||
}
|
||||
108
utils/notifications.tsx
Normal file
108
utils/notifications.tsx
Normal file
@ -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: (
|
||||
<div data-alt="insufficient-points-notification" style={{ minWidth: '280px' }}>
|
||||
<h3 style={messageStyle}>
|
||||
Insufficient credits reminder
|
||||
</h3>
|
||||
<p style={descriptionStyle}>Your credits are insufficient, please upgrade to continue.</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/pricing'}
|
||||
style={btnStyle}
|
||||
data-alt="recharge-button"
|
||||
>
|
||||
Upgrade to continue →
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
duration: 5,
|
||||
placement: 'topRight',
|
||||
style: darkGlassStyle,
|
||||
className: 'dark-glass-notification',
|
||||
closeIcon: (
|
||||
<button
|
||||
className="hover:text-white"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '2px',
|
||||
cursor: 'pointer',
|
||||
color: 'rgba(255, 255, 255, 0.45)',
|
||||
transition: 'color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局配置通知样式
|
||||
*/
|
||||
notification.config({
|
||||
maxCount: 3, // 最多同时显示3个通知
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user