This commit is contained in:
海龙 2025-08-28 00:24:15 +08:00
commit 03f822dba3
16 changed files with 1202 additions and 519 deletions

View File

@ -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
);

View File

@ -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;
}
};

View File

@ -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">

View File

@ -239,3 +239,7 @@ body {
.animate-fade-in {
animation: fade-in 0.2s ease-out forwards;
}
.ant-notification-notice-wrapper {
background: transparent !important;
}

View 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
View 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>
);
}

View File

@ -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;
}

View File

@ -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"

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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"

View File

@ -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
View 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
View 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个通知
});