forked from 77media/video-flow
merge
This commit is contained in:
commit
dc10dc6ed9
@ -33,8 +33,10 @@ export default function RootLayout({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Providers>
|
<Providers>
|
||||||
<ScreenAdapter />
|
{/* <ScreenAdapter /> */}
|
||||||
|
<div id="app" className='h-full w-full'>
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</Providers>
|
</Providers>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
197
app/payCallback/page.tsx
Normal file
197
app/payCallback/page.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Result, Button, Spin, Card } from 'antd'
|
||||||
|
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { getCheckoutSessionStatus, PaymentStatusResponse } from '@/lib/stripe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付状态枚举
|
||||||
|
*/
|
||||||
|
enum PaymentStatus {
|
||||||
|
LOADING = 'loading',
|
||||||
|
SUCCESS = 'success',
|
||||||
|
FAILED = 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付回调页面组件
|
||||||
|
* 处理Stripe Checkout支付完成后的状态展示和用户操作
|
||||||
|
*/
|
||||||
|
export default function PayCallbackPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus>(PaymentStatus.LOADING)
|
||||||
|
const [paymentInfo, setPaymentInfo] = useState<any>(null)
|
||||||
|
const callBackUrl = localStorage.getItem('callBackUrl') || '/'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Stripe Checkout Session支付状态
|
||||||
|
*/
|
||||||
|
const fetchPaymentStatus = async () => {
|
||||||
|
try {
|
||||||
|
// 从URL参数获取session_id和user_id
|
||||||
|
const sessionId = searchParams.get('session_id')
|
||||||
|
const userId = searchParams.get('user_id')
|
||||||
|
|
||||||
|
if (!sessionId || !userId) {
|
||||||
|
throw new Error('缺少必要的参数: session_id 或 user_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用真实的Stripe API获取支付状态
|
||||||
|
const response = await getCheckoutSessionStatus(sessionId, userId)
|
||||||
|
|
||||||
|
if (response.successful && response.data) {
|
||||||
|
const { payment_status, biz_order_no, pay_time, subscription } = response.data
|
||||||
|
|
||||||
|
if (payment_status === 'success') {
|
||||||
|
setPaymentStatus(PaymentStatus.SUCCESS)
|
||||||
|
setPaymentInfo({
|
||||||
|
orderId: biz_order_no,
|
||||||
|
sessionId,
|
||||||
|
paymentTime: pay_time,
|
||||||
|
subscription: subscription ? {
|
||||||
|
planName: subscription.plan_name,
|
||||||
|
planDisplayName: subscription.plan_display_name,
|
||||||
|
status: subscription.status,
|
||||||
|
currentPeriodEnd: subscription.current_period_end
|
||||||
|
} : null
|
||||||
|
})
|
||||||
|
} else if (payment_status === 'fail') {
|
||||||
|
setPaymentStatus(PaymentStatus.FAILED)
|
||||||
|
setPaymentInfo({
|
||||||
|
orderId: biz_order_no,
|
||||||
|
sessionId,
|
||||||
|
errorMessage: '支付处理失败,请重试'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// pending状态,继续等待
|
||||||
|
setTimeout(fetchPaymentStatus, 2000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取支付状态失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取支付状态失败:', error)
|
||||||
|
setPaymentStatus(PaymentStatus.FAILED)
|
||||||
|
setPaymentInfo({
|
||||||
|
errorMessage: error instanceof Error ? error.message : '网络错误,无法获取支付状态'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回上一页的函数
|
||||||
|
*/
|
||||||
|
const handleGoBack = () => {
|
||||||
|
router.push(callBackUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新支付的函数
|
||||||
|
*/
|
||||||
|
const handleRetryPayment = () => {
|
||||||
|
router.push('/pricing')
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPaymentStatus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
if (paymentStatus === PaymentStatus.LOADING) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||||
|
<div className="w-96 text-center bg-black border border-white/20 rounded-lg p-8">
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined style={{ fontSize: 48, color: 'white' }} spin />}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 text-lg text-white">正在获取支付状态...</div>
|
||||||
|
<div className="mt-2 text-sm text-white/70">请稍候,我们正在处理您的支付信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支付成功状态
|
||||||
|
if (paymentStatus === PaymentStatus.SUCCESS) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||||
|
<div className="w-[32rem] text-center bg-black border border-white/20 rounded-lg p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<CheckCircleOutlined className="text-white text-6xl mb-4" />
|
||||||
|
<h1 className="text-white text-2xl font-bold mb-2">支付成功!</h1>
|
||||||
|
<p className="text-white/70 text-base">{`订单号: ${paymentInfo?.orderId || 'N/A'}`}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-white/70 mb-8 space-y-2">
|
||||||
|
{paymentInfo?.subscription && (
|
||||||
|
<>
|
||||||
|
<p>订阅计划: {paymentInfo.subscription.planDisplayName}</p>
|
||||||
|
<p>订阅状态: {paymentInfo.subscription.status}</p>
|
||||||
|
{paymentInfo.subscription.currentPeriodEnd && (
|
||||||
|
<p>到期时间: {new Date(paymentInfo.subscription.currentPeriodEnd).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{paymentInfo?.paymentTime && (
|
||||||
|
<p>支付时间: {new Date(paymentInfo.paymentTime).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
<p>感谢您的购买!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={handleGoBack}
|
||||||
|
data-alt="back-to-previous-page-button"
|
||||||
|
className="w-full h-12 bg-white text-black hover:bg-white/90 border border-white/20"
|
||||||
|
>
|
||||||
|
返回上一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支付失败状态
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||||
|
<div className="w-[32rem] text-center bg-black border border-white/20 rounded-lg p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<CloseCircleOutlined className="text-white text-6xl mb-4" />
|
||||||
|
<h1 className="text-white text-2xl font-bold mb-2">支付失败</h1>
|
||||||
|
<p className="text-white/70 text-base">{paymentInfo?.errorMessage || '支付处理过程中发生错误'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-white/70 mb-8 space-y-2">
|
||||||
|
<p>订单号: {paymentInfo?.orderId || 'N/A'}</p>
|
||||||
|
<p>如果问题持续存在,请联系客服</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={handleRetryPayment}
|
||||||
|
data-alt="retry-payment-button"
|
||||||
|
className="w-full h-12 bg-white text-black hover:bg-white/90 border border-white/20"
|
||||||
|
>
|
||||||
|
重新支付
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
onClick={handleGoBack}
|
||||||
|
data-alt="back-to-previous-page-button"
|
||||||
|
className="w-full h-12 bg-black text-white hover:bg-white hover:text-black border border-white/20"
|
||||||
|
>
|
||||||
|
返回上一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Check, ArrowLeft } from 'lucide-react';
|
import { Check, ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -8,8 +8,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { fetchSubscriptionPlans, SubscriptionPlan } from '@/lib/stripe';
|
import { fetchSubscriptionPlans, SubscriptionPlan } from '@/lib/stripe';
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const router = useRouter();
|
|
||||||
const [billingCycle, setBillingCycle] = useState<'month' | 'year'>('month');
|
return (
|
||||||
|
<div className="w-full h-full overflow-y-auto bg-black text-white pb-[10rem]">
|
||||||
|
{/* Main Content */}
|
||||||
|
<HomeModule5 />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**价格方案 */
|
||||||
|
function HomeModule5() {
|
||||||
|
const [billingType, setBillingType] = useState<'month' | 'year'>(
|
||||||
|
"month"
|
||||||
|
);
|
||||||
|
|
||||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
|
||||||
// 从后端获取订阅计划数据
|
// 从后端获取订阅计划数据
|
||||||
@ -26,25 +38,23 @@ export default function PricingPage() {
|
|||||||
loadPlans();
|
loadPlans();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 转换后端数据为前端显示格式,保持原有的数据结构
|
const pricingPlans = useMemo<{
|
||||||
const transformPlanForDisplay = (plan: SubscriptionPlan) => {
|
title: string;
|
||||||
const monthlyPrice = plan.price_month / 100; // 后端存储的是分,转换为元
|
price: number;
|
||||||
const yearlyPrice = plan.price_year / 100;
|
credits: string;
|
||||||
|
buttonText: string;
|
||||||
|
features: string[];
|
||||||
|
}[]>(() => {
|
||||||
|
return plans.map((plan) => {
|
||||||
return {
|
return {
|
||||||
name: plan.name,
|
title: plan.display_name || plan.name,
|
||||||
displayName: plan.display_name,
|
price: billingType === "month" ? plan.price_month/100 : plan.price_year/100,
|
||||||
price: {
|
credits: plan.description,
|
||||||
month: monthlyPrice,
|
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
|
||||||
year: yearlyPrice
|
|
||||||
},
|
|
||||||
description: plan.description,
|
|
||||||
features: plan.features || [],
|
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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}, [plans, billingType]);
|
||||||
|
|
||||||
const handleSubscribe = async (planName: string) => {
|
const handleSubscribe = async (planName: string) => {
|
||||||
if (planName === 'hobby') {
|
if (planName === 'hobby') {
|
||||||
@ -66,7 +76,7 @@ export default function PricingPage() {
|
|||||||
const result = await createCheckoutSession({
|
const result = await createCheckoutSession({
|
||||||
user_id: String(User.id),
|
user_id: String(User.id),
|
||||||
plan_name: planName,
|
plan_name: planName,
|
||||||
billing_cycle: billingCycle
|
billing_cycle: billingType
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.successful || !result.data) {
|
if (!result.successful || !result.data) {
|
||||||
@ -80,203 +90,122 @@ export default function PricingPage() {
|
|||||||
throw new Error("create checkout session failed, please try again later");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
data-alt="core-value-section"
|
||||||
className={`flex flex-col h-full transition-all duration-300 ${
|
className="home-module5 h-[1600px] relative flex flex-col items-center justify-center w-full bg-black snap-start"
|
||||||
displayPlan.popular ? 'hover:scale-105 hover:shadow-2xl' : ''
|
|
||||||
}`}
|
|
||||||
style={{ minHeight: '540px' }}
|
|
||||||
>
|
>
|
||||||
{/* 预留标签空间,确保所有卡片组合高度一致 */}
|
<div
|
||||||
<div className="h-10 flex items-center justify-center">
|
data-alt="core-value-content"
|
||||||
{displayPlan.popular && (
|
className="center z-10 flex flex-col items-center mb-[8rem]"
|
||||||
<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
|
<h2 className="text-white text-[3.375rem] leading-[100%] font-normal mb-[1.5rem]">
|
||||||
</div>
|
Start Creating
|
||||||
)}
|
</h2>
|
||||||
</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 ${
|
<div className="h-[3.375rem] flex bg-black rounded-full p-[0.0625rem] mt-[1.5rem] border border-white/20">
|
||||||
displayPlan.popular ? 'ring-2 ring-pink-500/50 shadow-pink-500/20' : ''
|
<button
|
||||||
|
onClick={() => setBillingType("month")}
|
||||||
|
className={`box-border flex justify-center items-center w-[6rem] text-base rounded-full transition-all duration-300 ${
|
||||||
|
billingType === "month"
|
||||||
|
? "bg-white text-black"
|
||||||
|
: "text-white hover:text-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingType("year")}
|
||||||
|
className={`box-border flex justify-center items-center w-[7.125rem] text-base rounded-full transition-all duration-300 ${
|
||||||
|
billingType === "year"
|
||||||
|
? "bg-white text-black"
|
||||||
|
: "text-white hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Yearly - <span className="text-[#FFCC6D]">10%</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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="flex justify-between w-[90%] px-[12rem] mb-[2rem]">
|
||||||
|
{pricingPlans.map((plan, index) => (
|
||||||
<div className="mb-4">
|
<div
|
||||||
<div className="text-4xl font-bold text-white">
|
key={index}
|
||||||
{displayPlan.price[billingCycle] === 0 ? (
|
className=" w-[26rem] h-[38.125rem] bg-black rounded-lg p-[1.5rem] border border-white/20"
|
||||||
'Free'
|
>
|
||||||
) : (
|
<h3 className="text-white text-2xl font-normal mb-[1rem]">
|
||||||
<>
|
{plan.title}
|
||||||
<span className="text-5xl">${displayPlan.price[billingCycle]}</span>
|
</h3>
|
||||||
<span className="text-lg text-gray-400 font-normal">
|
<div className="mb-[1rem]">
|
||||||
/{billingCycle === 'month' ? 'month' : 'year'}
|
<span className="text-white text-[3.375rem] font-bold">
|
||||||
|
${plan.price}
|
||||||
</span>
|
</span>
|
||||||
</>
|
<span className="text-white text-xs ml-[0.5rem]">/month</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-white text-[0.875rem] mb-[1rem]">
|
||||||
|
{plan.credits}
|
||||||
<CardDescription className="text-gray-300 text-base px-4">
|
</p>
|
||||||
{displayPlan.description}
|
<button onClick={() => handleSubscribe(plan.title)} className="w-full bg-white text-black py-[0.75rem] rounded-full mb-[1rem] hover:bg-black hover:text-white transition-colors border border-white/20">
|
||||||
</CardDescription>
|
{plan.buttonText}
|
||||||
</CardHeader>
|
</button>
|
||||||
|
<p className="w-full text-center text-white/60 text-[0.75rem] mb-[2rem]">
|
||||||
<CardContent className="space-y-8 pb-10 flex-grow flex flex-col justify-between">
|
* Billed monthly until cancelled
|
||||||
<div className="space-y-5">
|
</p>
|
||||||
{displayPlan.features.map((feature, index) => (
|
<ul className="space-y-[1rem]">
|
||||||
<div key={index} className="flex items-start space-x-3">
|
{plan.features.map((feature, featureIndex) => (
|
||||||
<div className="w-5 h-5 bg-green-500/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
<li
|
||||||
<Check className="w-3 h-3 text-green-400" />
|
key={featureIndex}
|
||||||
</div>
|
className="flex items-center text-white text-[0.875rem]"
|
||||||
<span className="text-gray-300 text-sm leading-relaxed">{feature}</span>
|
>
|
||||||
|
<span className="text-[#C73BFF] mr-[0.5rem]">✓</span>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* 额外价格卡片 */}
|
||||||
onClick={() => handleSubscribe(displayPlan.name)}
|
<div className="flex gap-[1.5rem] w-[90%] px-[12rem]">
|
||||||
variant={displayPlan.buttonVariant}
|
<div className="flex-1 bg-black rounded-lg p-[1.5rem] border border-white/20">
|
||||||
className={`w-full py-4 rounded-xl font-medium text-base transition-all duration-300 mt-6 ${
|
<h3 className="text-white text-[1.5rem] font-bold mb-[0.5rem]">
|
||||||
displayPlan.buttonText === 'Start Free Trial'
|
Free
|
||||||
? 'border-2 border-gray-600 text-white hover:border-pink-500 hover:text-pink-400 hover:bg-gray-800/50'
|
</h3>
|
||||||
: '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'
|
<div className="mb-[1rem]">
|
||||||
}`}
|
<span className="text-white text-[2.5rem] font-bold">$0</span>
|
||||||
>
|
|
||||||
{displayPlan.buttonText}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
|
||||||
})}
|
10 Video mins and 1 AI credit per week, 1 Express avatar, 4 Exports
|
||||||
|
per week with invideo watermark.
|
||||||
|
</p>
|
||||||
|
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
|
||||||
|
No access to generative features.
|
||||||
|
</p>
|
||||||
|
<button className="w-[9rem] bg-[#262626] text-white py-[0.75rem] rounded-full hover:bg-white hover:text-black transition-colors border border-white/20">
|
||||||
|
Try For Free
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-black rounded-lg p-[1.5rem] border border-white/20">
|
||||||
|
<h3 className="text-white text-[1.5rem] font-bold mb-[0.5rem]">
|
||||||
|
Enterprise
|
||||||
|
</h3>
|
||||||
|
<p className="text-white text-[2.5rem] mb-[1rem]">Custom</p>
|
||||||
|
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
|
||||||
|
Custom solutions for large organizations. Advanced security and
|
||||||
|
flexible pricing based on your needs.
|
||||||
|
</p>
|
||||||
|
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
|
||||||
|
on your needs.
|
||||||
|
</p>
|
||||||
|
<button className="w-[9rem] bg-[#262626] text-white py-[0.75rem] rounded-full hover:bg-white hover:text-black transition-colors border border-white/20">
|
||||||
|
Contact Us
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,8 +173,8 @@ const RenderTemplateStoryMode = ({
|
|||||||
// 模板列表渲染
|
// 模板列表渲染
|
||||||
const templateListRender = () => {
|
const templateListRender = () => {
|
||||||
return (
|
return (
|
||||||
<div className="w-1/3 p-4 border-r border-white/[0.1]">
|
<div className="w-1/3 p-4 border-r border-white/[0.1] overflow-y-auto">
|
||||||
<div className="space-y-4 max-h-[700px] overflow-y-auto template-list-scroll">
|
<div className="space-y-4 overflow-y-auto template-list-scroll">
|
||||||
{templateStoryList.map((template, index) => (
|
{templateStoryList.map((template, index) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
@ -562,7 +562,7 @@ const RenderTemplateStoryMode = ({
|
|||||||
className="photo-story-modal !pb-0 rounded-lg bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
|
className="photo-story-modal !pb-0 rounded-lg bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
|
||||||
>
|
>
|
||||||
<GlobalLoad show={isLoading} progress={localLoading}>
|
<GlobalLoad show={isLoading} progress={localLoading}>
|
||||||
<div className="rounded-2xl min-h-min transition-all duration-700 ease-out">
|
<div className="rounded-2xl h-[70vh] overflow-y-hidden flex flex-col">
|
||||||
{/* 弹窗头部 */}
|
{/* 弹窗头部 */}
|
||||||
<div className="flex items-center justify-between px-4 pb-6 border-b border-white/[0.1]">
|
<div className="flex items-center justify-between px-4 pb-6 border-b border-white/[0.1]">
|
||||||
<h2 className="text-2xl font-bold text-white">
|
<h2 className="text-2xl font-bold text-white">
|
||||||
@ -576,7 +576,7 @@ const RenderTemplateStoryMode = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 pb-8 ">
|
<div className="flex gap-4 pb-8 flex-1 overflow-y-hidden">
|
||||||
{templateListRender()}
|
{templateListRender()}
|
||||||
<div className="flex-1">{storyEditorRender()}</div>
|
<div className="flex-1">{storyEditorRender()}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -591,7 +591,7 @@ const RenderTemplateStoryMode = ({
|
|||||||
* 视频工具面板组件
|
* 视频工具面板组件
|
||||||
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
|
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
|
||||||
*/
|
*/
|
||||||
export function ChatInputBox() {
|
export function ChatInputBox({ noData }: { noData: boolean }) {
|
||||||
// 控制面板展开/收起状态
|
// 控制面板展开/收起状态
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
@ -670,10 +670,15 @@ export function ChatInputBox() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]">
|
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]" style={noData ? {
|
||||||
|
top: '50%'
|
||||||
|
} : {}}>
|
||||||
{/* 视频故事板工具面板 - 毛玻璃效果背景 */}
|
{/* 视频故事板工具面板 - 毛玻璃效果背景 */}
|
||||||
<div className="video-storyboard-tools rounded-[20px] bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
<div className="video-storyboard-tools rounded-[20px] bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
||||||
{/* 展开/收起控制区域 */}
|
{/* 展开/收起控制区域 */}
|
||||||
|
{
|
||||||
|
!noData && (
|
||||||
|
<>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
// 展开状态:显示收起按钮和提示
|
// 展开状态:显示收起按钮和提示
|
||||||
<div
|
<div
|
||||||
@ -692,6 +697,9 @@ export function ChatInputBox() {
|
|||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* 主要内容区域 - 简化层级,垂直居中 */}
|
{/* 主要内容区域 - 简化层级,垂直居中 */}
|
||||||
<div
|
<div
|
||||||
@ -744,6 +752,9 @@ export function ChatInputBox() {
|
|||||||
onChange={(e) => setScript(e.target.value)}
|
onChange={(e) => setScript(e.target.value)}
|
||||||
placeholder="Describe the content you want to action..."
|
placeholder="Describe the content you want to action..."
|
||||||
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
||||||
|
style={noData ? {
|
||||||
|
minHeight: '128px'
|
||||||
|
} : {}}
|
||||||
rows={1}
|
rows={1}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const target = e.target as HTMLTextAreaElement;
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
|||||||
183
components/QueueBox/QueueNotification.tsx
Normal file
183
components/QueueBox/QueueNotification.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { notification } from 'antd';
|
||||||
|
|
||||||
|
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 filmStripContainerStyle = {
|
||||||
|
position: 'relative' as const,
|
||||||
|
width: '100%',
|
||||||
|
height: '80px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 胶片样式 */
|
||||||
|
const filmStripStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
animation: 'filmScroll 20s linear infinite',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 文字样式 */
|
||||||
|
const textStyle = {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 胶片帧组件 */
|
||||||
|
const FilmFrame = () => (
|
||||||
|
<div style={{ margin: '0 4px' }}>
|
||||||
|
<svg width="60" height="80" viewBox="0 0 60 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* 胶片外框 */}
|
||||||
|
<rect x="0" y="0" width="60" height="80" fill="#1A1B1E" stroke="#F6B266" strokeWidth="1"/>
|
||||||
|
{/* 齿孔 */}
|
||||||
|
<circle cx="10" cy="5" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
||||||
|
<circle cx="50" cy="5" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
||||||
|
<circle cx="10" cy="75" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
||||||
|
<circle cx="50" cy="75" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
||||||
|
{/* 胶片画面区域 */}
|
||||||
|
<rect x="5" y="15" width="50" height="50" fill="#2A2B2E" stroke="#F6B266" strokeWidth="1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 放映机音效组件 */
|
||||||
|
const ProjectorSound = () => (
|
||||||
|
<audio
|
||||||
|
src="/assets/audio/projector.mp3"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示队列等待通知
|
||||||
|
* @param position - 当前队列位置
|
||||||
|
* @param estimatedMinutes - 预计等待分钟数
|
||||||
|
*/
|
||||||
|
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
|
||||||
|
notification.open({
|
||||||
|
message: null,
|
||||||
|
description: (
|
||||||
|
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
|
||||||
|
{/* 胶片动画区域 */}
|
||||||
|
<div style={filmStripContainerStyle}>
|
||||||
|
<div style={filmStripStyle}>
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<FilmFrame key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={filmStripStyle} className="film-strip-2">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<FilmFrame key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 队列信息 */}
|
||||||
|
<div style={textStyle}>
|
||||||
|
<span style={{ marginRight: '8px' }}>🎬</span>
|
||||||
|
您的作品正在第 {position} 位等待制作
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预计等待时间 */}
|
||||||
|
<div style={{ ...textStyle, color: 'rgba(255, 255, 255, 0.65)' }}>
|
||||||
|
预计等待时间:约 {estimatedMinutes} 分钟
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 取消按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => notification.destroy()}
|
||||||
|
style={{
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
data-alt="cancel-queue-button"
|
||||||
|
>
|
||||||
|
取消制作 →
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 放映机音效 */}
|
||||||
|
<ProjectorSound />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: 0,
|
||||||
|
placement: 'topRight',
|
||||||
|
style: {
|
||||||
|
...darkGlassStyle,
|
||||||
|
border: '1px solid rgba(246, 178, 102, 0.2)',
|
||||||
|
},
|
||||||
|
className: 'movie-queue-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>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加必要的CSS动画
|
||||||
|
const styles = `
|
||||||
|
@keyframes filmScroll {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-360px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.film-strip-2 {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-queue-notification {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 将样式注入到页面
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const styleSheet = document.createElement('style');
|
||||||
|
styleSheet.textContent = styles;
|
||||||
|
document.head.appendChild(styleSheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置通知
|
||||||
|
notification.config({
|
||||||
|
maxCount: 3,
|
||||||
|
});
|
||||||
238
components/QueueBox/QueueNotification2.tsx
Normal file
238
components/QueueBox/QueueNotification2.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { notification } from 'antd';
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** AI导演工作室容器样式 */
|
||||||
|
const studioContainerStyle = {
|
||||||
|
position: 'relative' as const,
|
||||||
|
width: '100%',
|
||||||
|
height: '120px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
background: 'rgba(26, 27, 30, 0.6)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** AI导演组件 */
|
||||||
|
const AIDirector = () => (
|
||||||
|
<div className="ai-director">
|
||||||
|
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* AI导演的圆形头部 */}
|
||||||
|
<circle cx="50" cy="40" r="25" fill="#F6B266"/>
|
||||||
|
{/* 眼睛 */}
|
||||||
|
<circle cx="40" cy="35" r="5" fill="#2A2B2E"/>
|
||||||
|
<circle cx="60" cy="35" r="5" fill="#2A2B2E"/>
|
||||||
|
{/* 笑容 */}
|
||||||
|
<path d="M40 45 Q50 55 60 45" stroke="#2A2B2E" strokeWidth="3" strokeLinecap="round"/>
|
||||||
|
{/* 导演帽 */}
|
||||||
|
<path d="M25 30 H75 V25 H25" fill="#2A2B2E"/>
|
||||||
|
{/* 身体 */}
|
||||||
|
<rect x="35" y="65" width="30" height="25" fill="#F6B266"/>
|
||||||
|
{/* 手臂 - 动画中会移动 */}
|
||||||
|
<rect className="director-arm" x="25" y="70" width="15" height="5" fill="#F6B266"/>
|
||||||
|
<rect className="director-arm" x="60" y="70" width="15" height="5" fill="#F6B266"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 工作进度条组件 */
|
||||||
|
const ProgressTimeline = () => (
|
||||||
|
<div className="progress-timeline" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '10px',
|
||||||
|
left: '20px',
|
||||||
|
right: '20px',
|
||||||
|
height: '4px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}}>
|
||||||
|
<div className="progress-indicator" style={{
|
||||||
|
width: '30%',
|
||||||
|
height: '100%',
|
||||||
|
background: '#F6B266',
|
||||||
|
borderRadius: '2px',
|
||||||
|
animation: 'progress 2s ease-in-out infinite',
|
||||||
|
}}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 工作台元素组件 */
|
||||||
|
const Workstation = () => (
|
||||||
|
<div className="workstation" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '20px',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
}}>
|
||||||
|
{/* 小型场景图标,会在动画中浮动 */}
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className={`scene-icon scene-${i}`} style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
background: 'rgba(246, 178, 102, 0.3)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
animation: `float ${1 + i * 0.5}s ease-in-out infinite alternate`,
|
||||||
|
}}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示队列等待通知
|
||||||
|
* @param position - 当前队列位置
|
||||||
|
* @param estimatedMinutes - 预计等待分钟数
|
||||||
|
*/
|
||||||
|
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
|
||||||
|
notification.open({
|
||||||
|
message: null,
|
||||||
|
description: (
|
||||||
|
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
|
||||||
|
{/* AI导演工作室场景 */}
|
||||||
|
<div style={studioContainerStyle}>
|
||||||
|
<AIDirector />
|
||||||
|
<Workstation />
|
||||||
|
<ProgressTimeline />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 队列信息 */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'rgba(246, 178, 102, 0.1)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}>
|
||||||
|
<span style={{ marginRight: '8px' }}>🎬</span>
|
||||||
|
您的作品正在第 {position} 位等待制作
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预计等待时间 */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.65)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
预计等待时间:约 {estimatedMinutes} 分钟
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 取消按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => notification.destroy()}
|
||||||
|
style={{
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
data-alt="cancel-queue-button"
|
||||||
|
>
|
||||||
|
取消制作 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: 0,
|
||||||
|
placement: 'topRight',
|
||||||
|
style: {
|
||||||
|
...darkGlassStyle,
|
||||||
|
border: '1px solid rgba(246, 178, 102, 0.2)',
|
||||||
|
},
|
||||||
|
className: 'director-studio-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>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加必要的CSS动画
|
||||||
|
const styles = `
|
||||||
|
.ai-director {
|
||||||
|
animation: bounce 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.director-arm {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: wave 1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0% { transform: rotate(-5deg); }
|
||||||
|
100% { transform: rotate(5deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
100% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress {
|
||||||
|
0% { width: 0%; }
|
||||||
|
50% { width: 60%; }
|
||||||
|
100% { width: 30%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.director-studio-notification {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-0 { animation-delay: 0s; }
|
||||||
|
.scene-1 { animation-delay: 0.2s; }
|
||||||
|
.scene-2 { animation-delay: 0.4s; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 将样式注入到页面
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const styleSheet = document.createElement('style');
|
||||||
|
styleSheet.textContent = styles;
|
||||||
|
document.head.appendChild(styleSheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置通知
|
||||||
|
notification.config({
|
||||||
|
maxCount: 3,
|
||||||
|
});
|
||||||
209
components/QueueBox/queue-notification.tsx
Normal file
209
components/QueueBox/queue-notification.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 场记板动画样式 */
|
||||||
|
const clapperboardStyle = {
|
||||||
|
position: 'relative' as const,
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
marginRight: '12px',
|
||||||
|
animation: 'clap 2s infinite',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 场记板文字样式 */
|
||||||
|
const sceneTextStyle = {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#F6B266',
|
||||||
|
marginBottom: '8px',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 队列信息样式 */
|
||||||
|
const queueInfoStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
background: 'rgba(246, 178, 102, 0.1)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场记板SVG组件
|
||||||
|
*/
|
||||||
|
const Clapperboard = () => (
|
||||||
|
<div style={clapperboardStyle}>
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 4H20V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V4Z"
|
||||||
|
stroke="#F6B266" strokeWidth="1.5"/>
|
||||||
|
<path d="M4 8H20" stroke="#F6B266" strokeWidth="1.5"/>
|
||||||
|
<path d="M9 4L11 8M15 4L17 8" stroke="#F6B266" strokeWidth="1.5"/>
|
||||||
|
<path className="clap-top" d="M4 4L20 4L17 8L4 8L4 4Z"
|
||||||
|
fill="rgba(246, 178, 102, 0.2)" stroke="#F6B266" strokeWidth="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示队列等待通知
|
||||||
|
* @param position - 当前队列位置
|
||||||
|
* @param estimatedMinutes - 预计等待分钟数
|
||||||
|
*/
|
||||||
|
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
|
||||||
|
// 生成场景号和镜次号
|
||||||
|
const sceneNumber = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const takeNumber = Math.floor(Math.random() * 3) + 1;
|
||||||
|
|
||||||
|
notification.open({
|
||||||
|
message: null,
|
||||||
|
description: (
|
||||||
|
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
|
||||||
|
{/* 场记板和场景信息 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<Clapperboard />
|
||||||
|
<div>
|
||||||
|
<div style={sceneTextStyle}>
|
||||||
|
Scene {sceneNumber} - Take {takeNumber}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.6)' }}>
|
||||||
|
AI Director's Cut
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 队列信息 */}
|
||||||
|
<div style={queueInfoStyle}>
|
||||||
|
<span style={{ marginRight: '8px' }}>🎬</span>
|
||||||
|
您的作品正在第 {position} 位等待制作
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预计等待时间 */}
|
||||||
|
<div style={descriptionStyle}>
|
||||||
|
预计等待时间:约 {estimatedMinutes} 分钟
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 取消按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => notification.destroy()}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
marginTop: '8px',
|
||||||
|
}}
|
||||||
|
data-alt="cancel-queue-button"
|
||||||
|
>
|
||||||
|
取消制作 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: 0, // 保持通知直到用户关闭
|
||||||
|
placement: 'topRight',
|
||||||
|
style: {
|
||||||
|
...darkGlassStyle,
|
||||||
|
border: '1px solid rgba(246, 178, 102, 0.2)',
|
||||||
|
},
|
||||||
|
className: 'movie-queue-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>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加必要的CSS动画
|
||||||
|
const styles = `
|
||||||
|
@keyframes clap {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
5% { transform: rotate(-15deg); }
|
||||||
|
10% { transform: rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-queue-notification {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 将样式注入到页面
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const styleSheet = document.createElement('style');
|
||||||
|
styleSheet.textContent = styles;
|
||||||
|
document.head.appendChild(styleSheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持现有的notification配置
|
||||||
|
notification.config({
|
||||||
|
maxCount: 3,
|
||||||
|
});
|
||||||
@ -12,8 +12,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" min-h-screen bg-background" id="app">
|
<div className=" min-h-screen bg-background">
|
||||||
<Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />
|
|
||||||
<TopBar collapsed={sidebarCollapsed} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
<TopBar collapsed={sidebarCollapsed} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import '../pages/style/top-bar.css';
|
import "../pages/style/top-bar.css";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
import { GradientText } from '@/components/ui/gradient-text';
|
import { GradientText } from "@/components/ui/gradient-text";
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
@ -11,14 +11,14 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
LogOut,
|
LogOut,
|
||||||
PanelsLeftBottom,
|
PanelsLeftBottom,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import React, { useRef, useEffect, useLayoutEffect, useState } from 'react';
|
import React, { useRef, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import { logoutUser } from '@/lib/auth';
|
import { logoutUser } from '@/lib/auth';
|
||||||
import { createPortalSession, redirectToPortal } from '@/lib/stripe';
|
import { showQueueNotification } from '@/components/QueueBox/QueueNotification2';
|
||||||
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -27,15 +27,23 @@ interface User {
|
|||||||
avatar: string;
|
avatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onToggleSidebar: () => void }) {
|
export function TopBar({
|
||||||
|
collapsed,
|
||||||
|
onToggleSidebar,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleSidebar: () => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const currentUser: User = JSON.parse(localStorage.getItem('currentUser') || '{}');
|
const currentUser: User = JSON.parse(
|
||||||
|
localStorage.getItem("currentUser") || "{}"
|
||||||
|
);
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
const [isLogin, setIsLogin] = useState(false);
|
const [isLogin, setIsLogin] = useState(false);
|
||||||
const [isManagingSubscription, setIsManagingSubscription] = useState(false);
|
const pathname = usePathname()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentUser = localStorage.getItem("currentUser");
|
const currentUser = localStorage.getItem("currentUser");
|
||||||
if (JSON.parse(currentUser || "{}")?.token) {
|
if (JSON.parse(currentUser || "{}")?.token) {
|
||||||
@ -45,40 +53,11 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
console.log('Setting mounted state');
|
console.log("Setting mounted state");
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
return () => console.log('Cleanup mounted effect');
|
return () => console.log("Cleanup mounted effect");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 处理订阅管理
|
|
||||||
const handleManageSubscription = async () => {
|
|
||||||
if (!currentUser?.id) {
|
|
||||||
console.error('用户未登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsManagingSubscription(true);
|
|
||||||
try {
|
|
||||||
const response = await createPortalSession({
|
|
||||||
user_id: String(currentUser.id),
|
|
||||||
return_url: window.location.origin + '/dashboard'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.successful && response.data?.portal_url) {
|
|
||||||
redirectToPortal(response.data.portal_url);
|
|
||||||
} else {
|
|
||||||
console.error('创建订阅管理会话失败:', response.message);
|
|
||||||
alert('无法打开订阅管理页面,请稍后重试');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('打开订阅管理页面失败:', error);
|
|
||||||
alert('无法打开订阅管理页面,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setIsManagingSubscription(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// 处理点击事件
|
// 处理点击事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@ -88,16 +67,14 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
const handleMouseDown = (event: MouseEvent) => {
|
const handleMouseDown = (event: MouseEvent) => {
|
||||||
const target = event.target as Node;
|
const target = event.target as Node;
|
||||||
isClickStartedInside = !!(
|
isClickStartedInside = !!(
|
||||||
menuRef.current?.contains(target) ||
|
menuRef.current?.contains(target) || buttonRef.current?.contains(target)
|
||||||
buttonRef.current?.contains(target)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = (event: MouseEvent) => {
|
const handleMouseUp = (event: MouseEvent) => {
|
||||||
const target = event.target as Node;
|
const target = event.target as Node;
|
||||||
const isClickEndedInside = !!(
|
const isClickEndedInside = !!(
|
||||||
menuRef.current?.contains(target) ||
|
menuRef.current?.contains(target) || buttonRef.current?.contains(target)
|
||||||
buttonRef.current?.contains(target)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 只有当点击开始和结束都在外部时才关闭
|
// 只有当点击开始和结束都在外部时才关闭
|
||||||
@ -108,12 +85,12 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 在冒泡阶段处理事件
|
// 在冒泡阶段处理事件
|
||||||
document.addEventListener('mousedown', handleMouseDown);
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@ -128,21 +105,14 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed right-0 top-0 left-0 h-16 header z-[999]" style={{ isolation: 'isolate' }}>
|
<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="h-full flex items-center justify-between pr-6 pl-6">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{isLogin && (
|
|
||||||
<Button
|
|
||||||
className="button-NxtqWZ"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onToggleSidebar}
|
|
||||||
>
|
|
||||||
<PanelsLeftBottom className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center cursor-pointer space-x-4 link-logo roll event-on`}
|
className={`flex items-center cursor-pointer space-x-1 link-logo roll event-on`}
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onAnimationEnd={handleAnimationEnd}
|
onAnimationEnd={handleAnimationEnd}
|
||||||
@ -181,14 +151,17 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => router.push('/pricing')}
|
onClick={() => {
|
||||||
|
localStorage.setItem("callBackUrl", pathname);
|
||||||
|
router.push("/pricing");
|
||||||
|
}}
|
||||||
className="text-gray-300 hover:text-white"
|
className="text-gray-300 hover:text-white"
|
||||||
>
|
>
|
||||||
Pricing
|
Pricing
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
{/* <Button variant="ghost" size="sm" onClick={() => setOpenModal(true)}>
|
{/* <Button variant="ghost" size="sm" onClick={() => showQueueNotification(3, 10)}>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
</Button> */}
|
</Button> */}
|
||||||
|
|
||||||
@ -203,13 +176,13 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
</Button> */}
|
</Button> */}
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<div className="relative" style={{ isolation: 'isolate' }}>
|
<div className="relative" style={{ isolation: "isolate" }}>
|
||||||
<Button
|
<Button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('Button clicked, current isOpen:', isOpen);
|
console.log("Button clicked, current isOpen:", isOpen);
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
}}
|
}}
|
||||||
data-alt="user-menu-trigger"
|
data-alt="user-menu-trigger"
|
||||||
@ -217,10 +190,8 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{mounted && (
|
{mounted && isOpen
|
||||||
ReactDOM.createPortal(
|
? ReactDOM.createPortal(
|
||||||
(<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||||
@ -228,11 +199,11 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
top: '4rem',
|
top: "4rem",
|
||||||
right: '1rem',
|
right: "1rem",
|
||||||
width: '18rem',
|
width: "18rem",
|
||||||
zIndex: 9999
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
className="bg-[#1E1E1E] rounded-lg shadow-lg overflow-hidden"
|
className="bg-[#1E1E1E] rounded-lg shadow-lg overflow-hidden"
|
||||||
data-alt="user-menu-dropdown"
|
data-alt="user-menu-dropdown"
|
||||||
@ -242,14 +213,18 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center space-x-3">
|
<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">
|
<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) : ''}
|
{currentUser.name ? currentUser.name.charAt(0) : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1'>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{currentUser.name}</p>
|
<p className="text-sm font-medium">
|
||||||
<p className="text-xs text-gray-500">{currentUser.email}</p>
|
{currentUser.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{currentUser.email}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className='cursor-pointer hover:text-red-400 transition-colors duration-200'
|
className="cursor-pointer hover:text-red-400 transition-colors duration-200"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
logoutUser();
|
logoutUser();
|
||||||
}}
|
}}
|
||||||
@ -264,28 +239,27 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
<div className="px-4 py-3 flex items-center justify-between">
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
<span className="text-white underline text-sm">100 credits</span>
|
<span className="text-white underline text-sm">
|
||||||
|
100 credits
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-white border-white hover:bg-white/10 rounded-full px-8"
|
className="text-white border-white hover:bg-white/10 rounded-full px-3 py-1 text-[10px]"
|
||||||
onClick={() => router.push('/pricing')}
|
onClick={() => router.push("/pricing")}
|
||||||
>
|
>
|
||||||
Upgrade
|
Upgrade
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-white hover:bg-white/10 rounded-full px-4 text-xs"
|
className="text-white border-white hover:bg-white/10 rounded-full px-3 py-1 text-[10px]"
|
||||||
onClick={handleManageSubscription}
|
onClick={() => router.push("/pricing")}
|
||||||
disabled={isManagingSubscription}
|
|
||||||
>
|
>
|
||||||
{isManagingSubscription ? 'Loading...' : 'Manage Subscription'}
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
@ -318,16 +292,13 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
<div>250819215404 | 2025/8/20 06:00:50</div>
|
<div>250819215404 | 2025/8/20 06:00:50</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>,
|
||||||
)}
|
|
||||||
</AnimatePresence>) as React.ReactElement,
|
|
||||||
document.body
|
document.body
|
||||||
) as React.ReactNode
|
)
|
||||||
)}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import './style/create-to-video2.css';
|
|||||||
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||||||
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
||||||
|
import cover_image1 from '@/public/assets/cover_image1.jpg';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
|
||||||
// ideaText已迁移到ChatInputBox组件中
|
// ideaText已迁移到ChatInputBox组件中
|
||||||
@ -98,6 +100,139 @@ export default function CreateToVideo2() {
|
|||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const StatusBadge = (status: string) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-center gap-2 rounded-full
|
||||||
|
bg-white/10 border border-white/20
|
||||||
|
px-3 py-1 backdrop-blur-md shadow-[0_0_8px_rgba(255,255,255,0.3)]"
|
||||||
|
>
|
||||||
|
{/* 进行中 脉冲小圆点 */}
|
||||||
|
{status === 'pending' && (
|
||||||
|
<>
|
||||||
|
<motion.span
|
||||||
|
className="w-2 h-2 rounded-full bg-yellow-400 shadow-[0_0_8px_rgba(255,220,100,0.9)]"
|
||||||
|
animate={{ scale: [1, 1.4, 1] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
|
/>
|
||||||
|
{/* 状态文字 */}
|
||||||
|
<span className="text-xs tracking-widest text-yellow-300 font-medium drop-shadow-[0_0_6px_rgba(255,220,100,0.6)]">
|
||||||
|
PROCESSING
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* 已完成 */}
|
||||||
|
{status === 'completed' && (
|
||||||
|
<>
|
||||||
|
<motion.span
|
||||||
|
className="w-2 h-2 rounded-full bg-green-400 shadow-[0_0_8px_rgba(0,255,120,0.9)]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs tracking-widest text-green-300 font-medium drop-shadow-[0_0_6px_rgba(0,255,120,0.6)]">
|
||||||
|
COMPLETED
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* 失败 */}
|
||||||
|
{status === 'failed' && (
|
||||||
|
<>
|
||||||
|
<motion.span
|
||||||
|
className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_8px_rgba(255,0,80,0.9)]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs tracking-widest text-red-400 font-medium drop-shadow-[0_0_6px_rgba(255,0,80,0.6)]">
|
||||||
|
FAILED
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个视频引用Map
|
||||||
|
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
||||||
|
|
||||||
|
const handleMouseEnter = (projectId: string) => {
|
||||||
|
const videoElement = videoRefs.current.get(projectId);
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.play().catch(() => {
|
||||||
|
console.log('Video autoplay prevented');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (projectId: string) => {
|
||||||
|
const videoElement = videoRefs.current.get(projectId);
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.pause();
|
||||||
|
videoElement.currentTime = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVideoRef = (projectId: string, element: HTMLVideoElement | null) => {
|
||||||
|
if (element) {
|
||||||
|
videoRefs.current.set(projectId, element);
|
||||||
|
} else {
|
||||||
|
videoRefs.current.delete(projectId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProjectCard = (project: any) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.project_id}
|
||||||
|
className="group relative aspect-video bg-black/20 rounded-lg overflow-hidden cursor-pointer"
|
||||||
|
onClick={() => router.push(`/create/work-flow?episodeId=${project.project_id}`)}
|
||||||
|
onMouseEnter={() => handleMouseEnter(project.project_id)}
|
||||||
|
onMouseLeave={() => handleMouseLeave(project.project_id)}
|
||||||
|
data-alt="project-card"
|
||||||
|
>
|
||||||
|
{/* 视频/图片区域 */}
|
||||||
|
{project.final_video_url ? (
|
||||||
|
<video
|
||||||
|
ref={(el) => setVideoRef(project.project_id, el)}
|
||||||
|
src={project.final_video_url}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
poster={`${project.final_video_url}?vframe/jpg/offset/1`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-cover bg-center group-hover:scale-105 transition-transform duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${cover_image1.src})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 渐变遮罩 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||||
|
|
||||||
|
{/* 状态标签 - 左上角 */}
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
{StatusBadge(project.status === 'COMPLETED' ? 'completed' : project.status === 'FAILED' ? 'failed' : 'pending')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部信息 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-medium text-white group-hover:text-purple-400 transition-colors duration-300 line-clamp-1">
|
||||||
|
{project.name || "未命名项目"}
|
||||||
|
</h2>
|
||||||
|
<button className="p-1.5 hover:bg-white/10 rounded-full transition-colors opacity-0 group-hover:opacity-100">
|
||||||
|
<MoreHorizontal className="w-4 h-4 text-white/80" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染剧集卡片
|
// 渲染剧集卡片
|
||||||
const renderEpisodeCard = (episode: any) => {
|
const renderEpisodeCard = (episode: any) => {
|
||||||
return (
|
return (
|
||||||
@ -190,12 +325,12 @@ export default function CreateToVideo2() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container mx-auto h-[calc(100vh-6rem)] flex flex-col absolute top-[4rem] left-0 right-0 px-[1rem]">
|
<div className="flex flex-col absolute top-[5rem] left-0 right-0 bottom-[1rem] px-6">
|
||||||
{/* 优化后的主要内容区域 */}
|
{/* 优化后的主要内容区域 */}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="h-full overflow-y-auto custom-scrollbar"
|
className="h-full overflow-y-auto overflow-x-hidden custom-scrollbar"
|
||||||
style={{
|
style={{
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: 'thin',
|
||||||
scrollbarColor: 'rgba(255,255,255,0.1) transparent'
|
scrollbarColor: 'rgba(255,255,255,0.1) transparent'
|
||||||
@ -203,22 +338,34 @@ export default function CreateToVideo2() {
|
|||||||
>
|
>
|
||||||
{isLoading && episodeList.length === 0 ? (
|
{isLoading && episodeList.length === 0 ? (
|
||||||
/* 优化的加载状态 */
|
/* 优化的加载状态 */
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6 pb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 pb-6">
|
||||||
{[...Array(10)].map((_, index) => (
|
{[...Array(6)].map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-2xl overflow-hidden animate-pulse"
|
className="group relative aspect-video bg-black/20 rounded-lg overflow-hidden animate-pulse"
|
||||||
>
|
>
|
||||||
<div className="h-[200px] bg-gradient-to-br from-white/[0.08] to-white/[0.04]">
|
{/* 背景占位 */}
|
||||||
<div className="h-full bg-white/[0.06] animate-pulse"></div>
|
<div className="w-full h-full bg-gradient-to-br from-white/[0.04] to-white/[0.02]" />
|
||||||
|
|
||||||
|
{/* 渐变遮罩 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||||
|
|
||||||
|
{/* 状态标签占位 */}
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-white/20"></div>
|
||||||
|
<div className="w-16 h-3 bg-white/20 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
|
||||||
<div className="h-4 bg-white/[0.08] rounded-lg mb-3 animate-pulse"></div>
|
|
||||||
<div className="h-3 bg-white/[0.06] rounded-lg mb-4 w-3/4 animate-pulse"></div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div className="h-3 bg-white/[0.06] rounded-lg w-20 animate-pulse"></div>
|
|
||||||
<div className="h-3 bg-white/[0.06] rounded-lg w-16 animate-pulse"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 项目ID占位 */}
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<div className="w-20 h-3 bg-white/10 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部信息占位 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
|
<div className="w-2/3 h-5 bg-white/10 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -226,16 +373,16 @@ export default function CreateToVideo2() {
|
|||||||
) : episodeList.length > 0 ? (
|
) : episodeList.length > 0 ? (
|
||||||
/* 优化的剧集网格 */
|
/* 优化的剧集网格 */
|
||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{episodeList.map(renderEpisodeCard)}
|
{episodeList.map(renderProjectCard)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 加载更多指示器 */}
|
{/* 加载更多指示器 */}
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="flex items-center gap-3 px-6 py-3 bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-xl">
|
<div className="flex items-center gap-3 px-6 py-3 bg-black/30 backdrop-blur-xl border border-white/10 rounded-full">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
|
<Loader2 className="w-5 h-5 animate-spin text-purple-400" />
|
||||||
<span className="text-white/70 font-medium">Loading more episodes...</span>
|
<span className="text-white/90 font-medium">正在加载更多项目...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -244,10 +391,10 @@ export default function CreateToVideo2() {
|
|||||||
{!hasMore && episodeList.length > 0 && (
|
{!hasMore && episodeList.length > 0 && (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-12 h-12 bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-xl flex items-center justify-center mx-auto mb-3">
|
<div className="w-12 h-12 bg-black/30 backdrop-blur-xl border border-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
<Check className="w-6 h-6 text-green-400" />
|
<Check className="w-6 h-6 text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/50 text-sm">All episodes loaded</p>
|
<p className="text-white/70 text-sm">已加载全部项目</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -260,13 +407,9 @@ export default function CreateToVideo2() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视频工具组件 - 使用独立组件 */}
|
{/* 视频工具组件 - 使用独立组件 */}
|
||||||
<ChatInputBox />
|
{!isLoading &&
|
||||||
|
<ChatInputBox noData={episodeList.length === 0} />
|
||||||
{episodeList.length === 0 && !isLoading && (
|
}
|
||||||
<div className='h-full flex flex-col items-center fixed top-[4rem] left-0 right-0 bottom-0'>
|
|
||||||
<EmptyStateAnimation className='' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
CircleArrowRight,
|
CircleArrowRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import "./style/home-page2.css";
|
import "./style/home-page2.css";
|
||||||
import { useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
import "swiper/swiper-bundle.css"; // 引入样式
|
import "swiper/swiper-bundle.css"; // 引入样式
|
||||||
import { Autoplay } from "swiper/modules";
|
import { Autoplay } from "swiper/modules";
|
||||||
@ -26,17 +26,47 @@ import { ProjectTypeEnum, ModeEnum, ResolutionEnum } from "@/app/model/enums";
|
|||||||
import { getResourcesList, Resource } from "@/api/resources";
|
import { getResourcesList, Resource } from "@/api/resources";
|
||||||
import { Carousel } from "antd";
|
import { Carousel } from "antd";
|
||||||
import { TextCanvas } from "../common/TextCanvas";
|
import { TextCanvas } from "../common/TextCanvas";
|
||||||
|
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
||||||
|
|
||||||
export function HomePage2() {
|
export function HomePage2() {
|
||||||
const [hPading, setHPading] = useState(0);
|
const [hPading, setHPading] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHPading((window as any).Scale.hScale * 11);
|
// 获取当前窗口尺寸
|
||||||
|
const currentWidth = window.innerWidth;
|
||||||
|
const currentHeight = window.innerHeight;
|
||||||
|
// 计算缩放比例 (1920x1080)
|
||||||
|
const wScale = currentWidth / 1920;
|
||||||
|
const hScale = currentHeight / 1080;
|
||||||
|
|
||||||
|
// 检查app节点是否存在
|
||||||
|
const homePage = document.getElementById("home-page");
|
||||||
|
if (!homePage) {
|
||||||
|
console.error("未找到app节点");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// setHPading((hScale || 1) * 10);
|
||||||
|
// 创建样式元素
|
||||||
|
const style = document.createElement("style");
|
||||||
|
|
||||||
|
// 设置CSS样式
|
||||||
|
style.textContent = `
|
||||||
|
#home-page {
|
||||||
|
transform-origin: top left;
|
||||||
|
transform: scale(${wScale}, ${hScale});
|
||||||
|
width: 1920px;
|
||||||
|
height: 1080px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 将样式添加到head
|
||||||
|
document.head.appendChild(style);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
//
|
//
|
||||||
<div
|
<div
|
||||||
className="w-full h-full overflow-y-auto"
|
className="w-full h-screen overflow-y-auto"
|
||||||
style={{ paddingBottom: `${hPading}rem` }}
|
id="home-page"
|
||||||
|
style={{ paddingBottom: `2rem` }}
|
||||||
>
|
>
|
||||||
<HomeModule1 />
|
<HomeModule1 />
|
||||||
<HomeModule2 />
|
<HomeModule2 />
|
||||||
@ -52,7 +82,7 @@ export function HomePage2() {
|
|||||||
function HomeModule1() {
|
function HomeModule1() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<div className="home-module1 relative flex justify-center items-center w-full h-[1280px] bg-black snap-start">
|
<div className="home-module1 relative flex justify-center items-start pt-[28rem] w-full h-[1280px] bg-black snap-start">
|
||||||
<video
|
<video
|
||||||
src="/assets/home.mp4"
|
src="/assets/home.mp4"
|
||||||
autoPlay
|
autoPlay
|
||||||
@ -62,7 +92,7 @@ function HomeModule1() {
|
|||||||
className="absolute top-0 left-0 z-1 w-full h-full object-cover"
|
className="absolute top-0 left-0 z-1 w-full h-full object-cover"
|
||||||
></video>
|
></video>
|
||||||
<div className="center z-10 flex flex-col items-center">
|
<div className="center z-10 flex flex-col items-center">
|
||||||
<h1 className="text-white text-[5.75rem] leading-[100%] font-bold mb-[5rem]">
|
<h1 className="text-white text-[5.75rem] leading-[100%] font-bold mb-[1rem]">
|
||||||
Ideas Become Movies
|
Ideas Become Movies
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-white text-[2rem] leading-[140%] font-normal">
|
<p className="text-white text-[2rem] leading-[140%] font-normal">
|
||||||
@ -84,6 +114,20 @@ function HomeModule1() {
|
|||||||
}
|
}
|
||||||
/**核心价值 */
|
/**核心价值 */
|
||||||
function HomeModule2() {
|
function HomeModule2() {
|
||||||
|
const videoList = [
|
||||||
|
{
|
||||||
|
title: "Text to Movie",
|
||||||
|
video: "/assets/module2 (1).mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image to Movie",
|
||||||
|
video: "/assets/module2 (2).mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Template to Movie",
|
||||||
|
video: "/assets/module2 (3).mp4",
|
||||||
|
},
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-alt="core-value-section"
|
data-alt="core-value-section"
|
||||||
@ -104,17 +148,29 @@ function HomeModule2() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
data-alt="core-value-video"
|
data-alt="core-value-videos"
|
||||||
className="relative w-[80rem] h-[45rem] flex justify-center items-center border border-white/20 rounded-lg overflow-hidden"
|
className="flex justify-center gap-[1rem] w-full px-[4rem]"
|
||||||
|
>
|
||||||
|
{/* 第一个视频 */}
|
||||||
|
{videoList.map((item, index) => (
|
||||||
|
<div
|
||||||
|
data-alt="core-value-video-1"
|
||||||
|
className="flex flex-col items-center"
|
||||||
|
key={index}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
src="/assets/1.mp4"
|
src={item.video}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
className="w-full h-full object-cover"
|
className=" h-[20rem] object-cover border border-white/20 rounded-lg"
|
||||||
></video>
|
/>
|
||||||
|
<h3 className="mt-[1rem] text-white text-[1.5rem] font-medium">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -172,11 +228,12 @@ function HomeModule3() {
|
|||||||
<div
|
<div
|
||||||
className="absolute -top-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none"
|
className="absolute -top-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: "blur(12px)",
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
WebkitBackdropFilter: "blur(12px)",
|
||||||
backgroundColor: 'rgba(0,0,0,0.9)',
|
backgroundColor: "rgba(0,0,0,0.9)",
|
||||||
mask: 'linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)',
|
mask: "linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
|
||||||
WebkitMask: 'linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)'
|
WebkitMask:
|
||||||
|
"linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@ -184,11 +241,12 @@ function HomeModule3() {
|
|||||||
<div
|
<div
|
||||||
className="absolute -bottom-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none"
|
className="absolute -bottom-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: "blur(12px)",
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
WebkitBackdropFilter: "blur(12px)",
|
||||||
backgroundColor: 'rgba(0,0,0,0.9)',
|
backgroundColor: "rgba(0,0,0,0.9)",
|
||||||
mask: 'linear-gradient(to top, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)',
|
mask: "linear-gradient(to top, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
|
||||||
WebkitMask: 'linear-gradient(to top, black 0%, black 20%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)'
|
WebkitMask:
|
||||||
|
"linear-gradient(to top, black 0%, black 20%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
{videoList.map((column, columnIndex) => (
|
{videoList.map((column, columnIndex) => (
|
||||||
@ -250,24 +308,28 @@ function HomeModule4() {
|
|||||||
|
|
||||||
const processSteps = [
|
const processSteps = [
|
||||||
{
|
{
|
||||||
title: "Pre-Production",
|
title: "The Narrative Engine",
|
||||||
description:
|
description:
|
||||||
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
|
" From a single thought, it builds entire worlds and compelling plots.",
|
||||||
|
video: "/assets/module4 (3).mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Production",
|
title: "AI Character Engine",
|
||||||
description:
|
description:
|
||||||
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
|
"Cast your virtual actors. Lock them in once, for the entire story.",
|
||||||
|
video: "/assets/module4 (1).mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Visual Effects",
|
title: "AI vision engine",
|
||||||
description:
|
description:
|
||||||
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
|
"It translates your aesthetic into art, light, and cinematography for every single shot.",
|
||||||
|
video: "/assets/module4 (4).mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Voice",
|
title: "Intelligent Editing Engine",
|
||||||
description:
|
description:
|
||||||
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
|
"An editing AI drives the final cut, for a story told seamlessly.",
|
||||||
|
video: "/assets/module4 (2).mp4",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -284,7 +346,7 @@ function HomeModule4() {
|
|||||||
data-alt="core-value-content"
|
data-alt="core-value-content"
|
||||||
className="center z-10 flex flex-col items-center mb-[14rem]"
|
className="center z-10 flex flex-col items-center mb-[14rem]"
|
||||||
>
|
>
|
||||||
<h2 className="text-white text-[3.375rem] leading-[100%] font-normal mb-[3rem]">
|
<h2 className="text-white text-[3.375rem] leading-[100%] font-normal ">
|
||||||
Edit like you think
|
Edit like you think
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -327,7 +389,7 @@ function HomeModule4() {
|
|||||||
<div className="w-[80rem] h-[45rem] bg-gray-800 rounded-lg overflow-hidden border border-white/20">
|
<div className="w-[80rem] h-[45rem] bg-gray-800 rounded-lg overflow-hidden border border-white/20">
|
||||||
<video
|
<video
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
src="/assets/home.mp4"
|
src={processSteps[activeTab].video}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
@ -342,73 +404,82 @@ function HomeModule4() {
|
|||||||
}
|
}
|
||||||
/**价格方案 */
|
/**价格方案 */
|
||||||
function HomeModule5() {
|
function HomeModule5() {
|
||||||
const [billingType, setBillingType] = useState<"monthly" | "yearly">(
|
const [billingType, setBillingType] = useState<"month" | "year">("month");
|
||||||
"monthly"
|
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
const pathname = usePathname();
|
||||||
|
// 从后端获取订阅计划数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPlans = async () => {
|
||||||
|
try {
|
||||||
|
const plansData = await fetchSubscriptionPlans();
|
||||||
|
setPlans(plansData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("加载订阅计划失败:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pricingPlans = useMemo<
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
price: number;
|
||||||
|
credits: string;
|
||||||
|
buttonText: string;
|
||||||
|
features: string[];
|
||||||
|
}[]
|
||||||
|
>(() => {
|
||||||
|
return plans.map((plan) => {
|
||||||
|
return {
|
||||||
|
title: plan.display_name || plan.name,
|
||||||
|
price:
|
||||||
|
billingType === "month"
|
||||||
|
? plan.price_month / 100
|
||||||
|
: plan.price_year / 100,
|
||||||
|
credits: plan.description,
|
||||||
|
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
|
||||||
|
features: plan.features || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [plans, billingType]);
|
||||||
|
|
||||||
|
const handleSubscribe = async (planName: string) => {
|
||||||
|
if (planName === "hobby") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem("callBackUrl", pathname);
|
||||||
|
try {
|
||||||
|
// 使用新的Checkout Session方案(更简单!)
|
||||||
|
const { createCheckoutSession, redirectToCheckout } = await import(
|
||||||
|
"@/lib/stripe"
|
||||||
);
|
);
|
||||||
|
|
||||||
const pricingPlans = [
|
// 从localStorage获取当前用户信息
|
||||||
{
|
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||||||
title: "Plus",
|
|
||||||
price: billingType === "monthly" ? 28 : 24,
|
|
||||||
credits: "1x Boost, 10 Credits",
|
|
||||||
buttonText: "Choose Plus",
|
|
||||||
features: [
|
|
||||||
"10 Credits",
|
|
||||||
"50 Video mins + 95 iStock",
|
|
||||||
"2 UGC product asset ads",
|
|
||||||
"30 secs of generative video",
|
|
||||||
"2 express clones",
|
|
||||||
"3 users, 100GB storage",
|
|
||||||
"Unlimited exports",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Max",
|
|
||||||
price: billingType === "monthly" ? 50 : 43,
|
|
||||||
credits: "1x Boost, 40 Credits",
|
|
||||||
buttonText: "Choose Max",
|
|
||||||
features: [
|
|
||||||
"40 Credits",
|
|
||||||
"50 Video mins + 95 iStock",
|
|
||||||
"2 UGC product asset ads",
|
|
||||||
"30 secs of generative video",
|
|
||||||
"2 express clones",
|
|
||||||
"3 users, 100GB storage",
|
|
||||||
"Unlimited exports",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Generative",
|
|
||||||
price: billingType === "monthly" ? 100 : 85,
|
|
||||||
credits: "1x Boost, 100 Credits",
|
|
||||||
buttonText: "Choose Generative",
|
|
||||||
features: [
|
|
||||||
"100 Credits",
|
|
||||||
"50 Video mins + 95 iStock",
|
|
||||||
"2 UGC product asset ads",
|
|
||||||
"30 secs of generative video",
|
|
||||||
"2 express clones",
|
|
||||||
"3 users, 100GB storage",
|
|
||||||
"Unlimited exports",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Team",
|
|
||||||
price: billingType === "monthly" ? 899 : 764,
|
|
||||||
credits: "1x Boost, 1000 Credits",
|
|
||||||
buttonText: "Choose Team",
|
|
||||||
features: [
|
|
||||||
"1000 Credits",
|
|
||||||
"50 Video mins + 95 iStock",
|
|
||||||
"2 UGC product asset ads",
|
|
||||||
"30 secs of generative video",
|
|
||||||
"2 express clones",
|
|
||||||
"3 users, 100GB storage",
|
|
||||||
"Unlimited exports",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
if (!User.id) {
|
||||||
|
throw new Error("无法获取用户ID,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 创建Checkout Session
|
||||||
|
const result = await createCheckoutSession({
|
||||||
|
user_id: String(User.id),
|
||||||
|
plan_name: planName,
|
||||||
|
billing_cycle: billingType,
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-alt="core-value-section"
|
data-alt="core-value-section"
|
||||||
@ -425,9 +496,9 @@ function HomeModule5() {
|
|||||||
{/* 计费切换 */}
|
{/* 计费切换 */}
|
||||||
<div className="h-[3.375rem] flex bg-black rounded-full p-[0.0625rem] mt-[1.5rem] border border-white/20">
|
<div className="h-[3.375rem] flex bg-black rounded-full p-[0.0625rem] mt-[1.5rem] border border-white/20">
|
||||||
<button
|
<button
|
||||||
onClick={() => setBillingType("monthly")}
|
onClick={() => setBillingType("month")}
|
||||||
className={`box-border flex justify-center items-center w-[6rem] text-base rounded-full transition-all duration-300 ${
|
className={`box-border flex justify-center items-center w-[6rem] text-base rounded-full transition-all duration-300 ${
|
||||||
billingType === "monthly"
|
billingType === "month"
|
||||||
? "bg-white text-black"
|
? "bg-white text-black"
|
||||||
: "text-white hover:text-gray-300"
|
: "text-white hover:text-gray-300"
|
||||||
}`}
|
}`}
|
||||||
@ -435,24 +506,24 @@ function HomeModule5() {
|
|||||||
Monthly
|
Monthly
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBillingType("yearly")}
|
onClick={() => setBillingType("year")}
|
||||||
className={`box-border flex justify-center items-center w-[7.125rem] text-base rounded-full transition-all duration-300 ${
|
className={`box-border flex justify-center items-center w-[7.125rem] text-base rounded-full transition-all duration-300 ${
|
||||||
billingType === "yearly"
|
billingType === "year"
|
||||||
? "bg-white text-black"
|
? "bg-white text-black"
|
||||||
: "text-white hover:text-gray-300"
|
: "text-white hover:text-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Yearly - <span className="text-[#FFCC6D]">15%</span>
|
Yearly - <span className="text-[#FFCC6D]">10%</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主要价格卡片 */}
|
{/* 主要价格卡片 */}
|
||||||
<div className="grid grid-cols-4 gap-[1.5rem] w-full px-[12rem] mb-[2rem]">
|
<div className="flex justify-between w-[90%] px-[12rem] mb-[2rem]">
|
||||||
{pricingPlans.map((plan, index) => (
|
{pricingPlans.map((plan, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className=" h-[38.125rem] bg-black rounded-lg p-[1.5rem] border border-white/20"
|
className=" w-[24rem] h-[38.125rem] bg-black rounded-lg p-[1.5rem] border border-white/20"
|
||||||
>
|
>
|
||||||
<h3 className="text-white text-2xl font-normal mb-[1rem]">
|
<h3 className="text-white text-2xl font-normal mb-[1rem]">
|
||||||
{plan.title}
|
{plan.title}
|
||||||
@ -466,7 +537,10 @@ function HomeModule5() {
|
|||||||
<p className="text-white text-[0.875rem] mb-[1rem]">
|
<p className="text-white text-[0.875rem] mb-[1rem]">
|
||||||
{plan.credits}
|
{plan.credits}
|
||||||
</p>
|
</p>
|
||||||
<button className="w-full bg-white text-black py-[0.75rem] rounded-full mb-[1rem] hover:bg-black hover:text-white transition-colors border border-white/20">
|
<button
|
||||||
|
onClick={() => handleSubscribe(plan.title)}
|
||||||
|
className="w-full bg-white text-black py-[0.75rem] rounded-full mb-[1rem] hover:bg-black hover:text-white transition-colors border border-white/20"
|
||||||
|
>
|
||||||
{plan.buttonText}
|
{plan.buttonText}
|
||||||
</button>
|
</button>
|
||||||
<p className="w-full text-center text-white/60 text-[0.75rem] mb-[2rem]">
|
<p className="w-full text-center text-white/60 text-[0.75rem] mb-[2rem]">
|
||||||
@ -488,7 +562,7 @@ function HomeModule5() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 额外价格卡片 */}
|
{/* 额外价格卡片 */}
|
||||||
<div className="flex gap-[1.5rem] w-full px-[12rem]">
|
<div className="flex gap-[1.5rem] w-[90%] px-[12rem]">
|
||||||
<div className="flex-1 bg-black rounded-lg p-[1.5rem] border border-white/20">
|
<div className="flex-1 bg-black rounded-lg p-[1.5rem] border border-white/20">
|
||||||
<h3 className="text-white text-[1.5rem] font-bold mb-[0.5rem]">
|
<h3 className="text-white text-[1.5rem] font-bold mb-[0.5rem]">
|
||||||
Free
|
Free
|
||||||
|
|||||||
BIN
public/assets/cover_image1.jpg
Normal file
BIN
public/assets/cover_image1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
public/assets/cover_image2.jpg
Normal file
BIN
public/assets/cover_image2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
BIN
public/assets/module2 (1).mp4
Normal file
BIN
public/assets/module2 (1).mp4
Normal file
Binary file not shown.
BIN
public/assets/module2 (2).mp4
Normal file
BIN
public/assets/module2 (2).mp4
Normal file
Binary file not shown.
BIN
public/assets/module2 (3).mp4
Normal file
BIN
public/assets/module2 (3).mp4
Normal file
Binary file not shown.
BIN
public/assets/module4 (1).mp4
Normal file
BIN
public/assets/module4 (1).mp4
Normal file
Binary file not shown.
BIN
public/assets/module4 (2).mp4
Normal file
BIN
public/assets/module4 (2).mp4
Normal file
Binary file not shown.
BIN
public/assets/module4 (3).mp4
Normal file
BIN
public/assets/module4 (3).mp4
Normal file
Binary file not shown.
BIN
public/assets/module4 (4).mp4
Normal file
BIN
public/assets/module4 (4).mp4
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user