forked from 77media/video-flow
Merge branch 'dev' into prod
This commit is contained in:
commit
ef6c4b3e97
@ -1,5 +1,5 @@
|
|||||||
|
NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
|
||||||
NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
|
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
|
||||||
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||||
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
|
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
|
||||||
|
|
||||||
|
|||||||
@ -69,13 +69,14 @@ interface ListMovieProjectsParams {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MovieProject {
|
export interface MovieProject {
|
||||||
project_id: string;
|
project_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
step: string;
|
step: string;
|
||||||
final_video_url: string;
|
final_video_url: string;
|
||||||
final_simple_video_url: string;
|
final_simple_video_url: string;
|
||||||
|
video_urls: string;
|
||||||
last_message: string;
|
last_message: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { post } from "@/api/request";
|
import { post } from "@/api/request";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
|
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function Activate() {
|
export default function Activate() {
|
||||||
@ -21,10 +22,12 @@ export default function Activate() {
|
|||||||
* @param {string} t - Verification token
|
* @param {string} t - Verification token
|
||||||
*/
|
*/
|
||||||
function ConfirmEmail({ t }: { t: string }) {
|
function ConfirmEmail({ t }: { t: string }) {
|
||||||
|
const router = useRouter();
|
||||||
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
||||||
"loading"
|
"loading"
|
||||||
);
|
);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [countdown, setCountdown] = useState(3);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!t) {
|
if (!t) {
|
||||||
@ -35,18 +38,36 @@ function ConfirmEmail({ t }: { t: string }) {
|
|||||||
post(`/auth/activate`, {
|
post(`/auth/activate`, {
|
||||||
t: t,
|
t: t,
|
||||||
}).then((res:any) => {
|
}).then((res:any) => {
|
||||||
console.log('res', res)
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setMessage(
|
setMessage(
|
||||||
"Your registration has been verified. Please return to the official website to log in."
|
"Your registration has been verified. Redirecting to login page..."
|
||||||
);
|
);
|
||||||
}).catch((err:any) => {
|
}).catch((err:any) => {
|
||||||
console.log('err', err)
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setMessage("Verification failed. Please try again.");
|
setMessage("Verification failed. Please try again.");
|
||||||
});
|
});
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle countdown and redirect to login page
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "success") {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev: number) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
router.push("/login");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "loading":
|
case "loading":
|
||||||
@ -77,6 +98,18 @@ function ConfirmEmail({ t }: { t: string }) {
|
|||||||
Verification Successful
|
Verification Successful
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-300 text-center max-w-md">{message}</p>
|
<p className="text-gray-300 text-center max-w-md">{message}</p>
|
||||||
|
<div className="mt-4 p-4 bg-gradient-to-r from-cyan-400/10 to-purple-600/10 rounded-lg border border-cyan-400/20">
|
||||||
|
<p className="text-cyan-400 text-sm">
|
||||||
|
Redirecting to login page in {countdown} seconds...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
data-alt="manual-login-button"
|
||||||
|
onClick={() => router.push("/login")}
|
||||||
|
className="mt-2 px-6 py-2 bg-gradient-to-r from-cyan-400 to-purple-600 text-white rounded-lg hover:from-cyan-500 hover:to-purple-700 transition-all duration-300 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||||
|
>
|
||||||
|
Go to Login Now
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,23 @@ export async function POST(request: NextRequest) {
|
|||||||
updated_at: new Date().toISOString().slice(0, 19)
|
updated_at: new Date().toISOString().slice(0, 19)
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'video_modification':
|
||||||
|
// 视频修改功能配置 - 控制视频编辑笔图标显示
|
||||||
|
// 可以通过查询参数 ?show=false 来测试隐藏功能
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const showParam = url.searchParams.get('show');
|
||||||
|
const showValue = showParam !== null ? showParam === 'true' : true; // 默认显示
|
||||||
|
|
||||||
|
responseData = {
|
||||||
|
id: 9,
|
||||||
|
code: 'video_modification',
|
||||||
|
value: `{\n "show": ${showValue}\n}`,
|
||||||
|
note: '视频修改功能开关',
|
||||||
|
updated_at: new Date().toISOString().slice(0, 19)
|
||||||
|
};
|
||||||
|
console.log('📋 video_modification配置:', { showParam, showValue, value: responseData.value });
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 默认返回空配置
|
// 默认返回空配置
|
||||||
|
|||||||
@ -197,36 +197,7 @@ export default function SharePage(): JSX.Element {
|
|||||||
<p data-alt="subtitle" className="mt-1 text-sm text-white/60">Invite friends to join and earn rewards.</p>
|
<p data-alt="subtitle" className="mt-1 text-sm text-white/60">Invite friends to join and earn rewards.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{/* Section 1: My Invitation Link */}
|
||||||
{/* Section 1: Invite Flow */}
|
|
||||||
<section data-alt="invite-flow" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
|
||||||
<h2 data-alt="section-title" className="text-lg font-medium text-white">Invitation Flow</h2>
|
|
||||||
<ol data-alt="steps" className="mt-4 grid gap-4 sm:grid-cols-3">
|
|
||||||
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
|
|
||||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
|
||||||
</div>
|
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
|
|
||||||
</li>
|
|
||||||
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
|
|
||||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
|
||||||
</div>
|
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
|
|
||||||
</li>
|
|
||||||
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
|
||||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
|
||||||
</div>
|
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Section 2: My Invitation Link */}
|
|
||||||
<section data-alt="my-invite-link" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
<section data-alt="my-invite-link" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||||
<div data-alt="link-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
|
<div data-alt="link-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
|
||||||
<div data-alt="link-box" className="sm:col-span-2">
|
<div data-alt="link-box" className="sm:col-span-2">
|
||||||
@ -277,6 +248,74 @@ export default function SharePage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2: Invite Flow - Two Columns (Left: Steps, Right: Rules) */}
|
||||||
|
<section data-alt="invite-flow" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||||
|
<div data-alt="two-col-wrapper" className="mt-4 grid grid-cols-1 gap-6 md:grid-cols-[30%_1fr]">
|
||||||
|
{/* Left: Steps */}
|
||||||
|
<div data-alt="steps-col" className="space-y-4">
|
||||||
|
<h2 data-alt="section-title" className="text-lg font-medium text-white">Invitation Flow</h2>
|
||||||
|
<ol data-alt="steps" className="space-y-4">
|
||||||
|
<li data-alt="step" className="rounded-md p-4">
|
||||||
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
|
||||||
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
||||||
|
</div>
|
||||||
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
|
||||||
|
</li>
|
||||||
|
<li data-alt="step" className="rounded-md p-4">
|
||||||
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
|
||||||
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
||||||
|
</div>
|
||||||
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
|
||||||
|
</li>
|
||||||
|
<li data-alt="step" className="rounded-md p-4">
|
||||||
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
||||||
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
||||||
|
</div>
|
||||||
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{/* Right: Rules */}
|
||||||
|
<div data-alt="rules-col" className="rounded-md">
|
||||||
|
<h2 data-alt="section-title" className="text-lg font-medium text-white mb-4">MovieFlow Credits Rewards Program</h2>
|
||||||
|
<div className='p-4 space-y-4'>
|
||||||
|
<p className="text-sm">Welcome to MovieFlow! Our Credits Program is designed to reward your growth and contributions. Credits can be redeemed for premium templates, effects, and membership time.</p>
|
||||||
|
|
||||||
|
<div className="content">
|
||||||
|
<h2 className="text-medium font-medium text-white">How to Earn Credits?</h2>
|
||||||
|
|
||||||
|
<div className="reward-section welcome">
|
||||||
|
<h3 className="text-medium font-medium text-white">Welcome Bonus</h3>
|
||||||
|
<p className='text-sm'>All <strong>new users</strong> receive a bonus of <span className="credit-amount">500 credits</span> upon successful registration!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reward-section invite">
|
||||||
|
<h3 className="text-medium font-medium text-white">Invite & Earn</h3>
|
||||||
|
<p className='text-sm'>Invite friends to join using your unique referral link. Both you and your friend will get <span className="credit-amount">500 credits</span> once they successfully sign up.</p>
|
||||||
|
|
||||||
|
<div className="highlight">
|
||||||
|
<p className='text-sm'>If your invited friend completes their first purchase, you will receive a <strong>bonus equal to 20% of the credits</strong> they earn from that purchase.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reward-section login">
|
||||||
|
<h3 className="text-medium font-medium text-white">Daily Login</h3>
|
||||||
|
<p className='text-sm'>Starting the day after registration, log in daily to claim <span className="credit-amount">100 credits</span>.</p>
|
||||||
|
<p className='text-sm'>This reward can be claimed for <strong>7 consecutive days</strong>.</p>
|
||||||
|
|
||||||
|
<div className="note">
|
||||||
|
<p className='text-sm'><strong>Please note:</strong> Daily login credits will <strong>reset</strong> automatically on the 8th day, so remember to use them in time!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/* Section 3: Invite Records */}
|
{/* Section 3: Invite Records */}
|
||||||
<section data-alt="invite-records" className="rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
<section data-alt="invite-records" className="rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||||
<div data-alt="section-header" className="mb-4 flex items-center justify-between">
|
<div data-alt="section-header" className="mb-4 flex items-center justify-between">
|
||||||
|
|||||||
112
app/test-server-config/page.tsx
Normal file
112
app/test-server-config/page.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { isVideoModificationEnabled, isGoogleLoginEnabled } from '@/lib/server-config';
|
||||||
|
|
||||||
|
export default function TestServerConfigPage() {
|
||||||
|
const [ssoStatus, setSsoStatus] = useState<boolean | null>(null);
|
||||||
|
const [videoModStatus, setVideoModStatus] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const testConfigs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🧪 开始测试服务器配置...');
|
||||||
|
|
||||||
|
// 测试SSO配置
|
||||||
|
const ssoEnabled = await isGoogleLoginEnabled();
|
||||||
|
console.log('📋 SSO配置结果:', ssoEnabled);
|
||||||
|
setSsoStatus(ssoEnabled);
|
||||||
|
|
||||||
|
// 测试视频修改配置
|
||||||
|
const videoModEnabled = await isVideoModificationEnabled();
|
||||||
|
console.log('📋 视频修改配置结果:', videoModEnabled);
|
||||||
|
setVideoModStatus(videoModEnabled);
|
||||||
|
|
||||||
|
console.log('✅ 所有配置测试完成');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ 配置测试失败:', err);
|
||||||
|
setError(err instanceof Error ? err.message : '未知错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
testConfigs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-center">服务器配置测试页面</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">配置状态</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded">
|
||||||
|
<span className="font-medium">Google登录 (sso_config):</span>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
ssoStatus === null ? 'bg-gray-200 text-gray-600' :
|
||||||
|
ssoStatus ? 'bg-green-200 text-green-800' : 'bg-red-200 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{ssoStatus === null ? '检测中...' : ssoStatus ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded">
|
||||||
|
<span className="font-medium">视频修改 (video_modification):</span>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
videoModStatus === null ? 'bg-gray-200 text-gray-600' :
|
||||||
|
videoModStatus ? 'bg-green-200 text-green-800' : 'bg-red-200 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{videoModStatus === null ? '检测中...' : videoModStatus ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded">
|
||||||
|
<p className="text-red-800 font-medium">错误:</p>
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={testConfigs}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '测试中...' : '重新测试'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/movies/work-flow'}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
|
||||||
|
>
|
||||||
|
前往Work-Flow页面
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">API测试信息</h2>
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
|
<p><strong>SSO API:</strong> POST /api/server-setting/find_by_code {"{ code: 'sso_config' }"}</p>
|
||||||
|
<p><strong>视频修改API:</strong> POST /api/server-setting/find_by_code {"{ code: 'video_modification' }"}</p>
|
||||||
|
<p><strong>预期响应格式:</strong> {"{ code: 0, successful: true, data: { value: '{\"show\": true}' } }"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-500">
|
||||||
|
<p>请打开浏览器开发者工具查看详细的API调用日志</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@ -103,6 +103,33 @@ const debounce = (func: Function, wait: number) => {
|
|||||||
*/
|
*/
|
||||||
export function ChatInputBox({ noData }: { noData: boolean }) {
|
export function ChatInputBox({ noData }: { noData: boolean }) {
|
||||||
const { isMobile, isDesktop } = useDeviceType();
|
const { isMobile, isDesktop } = useDeviceType();
|
||||||
|
|
||||||
|
// 模板快捷入口拖动相关状态
|
||||||
|
const templateScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isTemplateDragging, setIsTemplateDragging] = useState(false);
|
||||||
|
const [templateStartX, setTemplateStartX] = useState(0);
|
||||||
|
const [templateScrollLeft, setTemplateScrollLeft] = useState(0);
|
||||||
|
|
||||||
|
// 模板快捷入口拖动事件处理
|
||||||
|
const handleTemplateMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsTemplateDragging(true);
|
||||||
|
setTemplateStartX(e.pageX - templateScrollRef.current!.offsetLeft);
|
||||||
|
setTemplateScrollLeft(templateScrollRef.current!.scrollLeft);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTemplateMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isTemplateDragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const x = e.pageX - templateScrollRef.current!.offsetLeft;
|
||||||
|
const walk = (x - templateStartX) * 2;
|
||||||
|
templateScrollRef.current!.scrollLeft = templateScrollLeft - walk;
|
||||||
|
}, [isTemplateDragging, templateStartX, templateScrollLeft]);
|
||||||
|
|
||||||
|
const handleTemplateMouseUp = useCallback(() => {
|
||||||
|
setIsTemplateDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 控制面板展开/收起状态
|
// 控制面板展开/收起状态
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
@ -148,7 +175,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
language: "english",
|
language: "english",
|
||||||
videoDuration: "unlimited",
|
videoDuration: "unlimited",
|
||||||
expansion_mode: true,
|
expansion_mode: true,
|
||||||
aspect_ratio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
aspect_ratio: isMobile ? "VIDEO_ASPECT_RATIO_PORTRAIT" : "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 从 localStorage 初始化配置
|
// 从 localStorage 初始化配置
|
||||||
@ -163,19 +190,38 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
language: parsed.language || "english",
|
language: parsed.language || "english",
|
||||||
videoDuration: parsed.videoDuration || "unlimited",
|
videoDuration: parsed.videoDuration || "unlimited",
|
||||||
expansion_mode: typeof parsed.expansion_mode === 'boolean' ? parsed.expansion_mode : true,
|
expansion_mode: typeof parsed.expansion_mode === 'boolean' ? parsed.expansion_mode : true,
|
||||||
aspect_ratio: parsed.aspect_ratio || "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
aspect_ratio: parsed.aspect_ratio || (isMobile ? "VIDEO_ASPECT_RATIO_PORTRAIT" : "VIDEO_ASPECT_RATIO_LANDSCAPE"),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('解析保存的配置失败,使用默认配置:', error);
|
console.warn('解析保存的配置失败,使用默认配置:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isMobile]);
|
||||||
|
|
||||||
|
// 跟踪用户是否手动修改过宽高比
|
||||||
|
const [hasUserChangedAspectRatio, setHasUserChangedAspectRatio] = useState(false);
|
||||||
|
|
||||||
|
// 监听设备类型变化,仅在用户未手动修改时动态调整默认宽高比
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasUserChangedAspectRatio) {
|
||||||
|
setConfigOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
aspect_ratio: isMobile ? "VIDEO_ASPECT_RATIO_PORTRAIT" : "VIDEO_ASPECT_RATIO_LANDSCAPE"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [isMobile, hasUserChangedAspectRatio]);
|
||||||
|
|
||||||
const onConfigChange = <K extends keyof ConfigOptions>(key: K, value: ConfigOptions[K]) => {
|
const onConfigChange = <K extends keyof ConfigOptions>(key: K, value: ConfigOptions[K]) => {
|
||||||
setConfigOptions((prev: ConfigOptions) => ({
|
setConfigOptions((prev: ConfigOptions) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 如果用户手动修改了宽高比,标记为已修改
|
||||||
|
if (key === 'aspect_ratio') {
|
||||||
|
setHasUserChangedAspectRatio(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'videoDuration') {
|
if (key === 'videoDuration') {
|
||||||
// 当选择 8s 时,强制关闭剧本扩展并禁用开关
|
// 当选择 8s 时,强制关闭剧本扩展并禁用开关
|
||||||
if (value === '8s') {
|
if (value === '8s') {
|
||||||
@ -510,7 +556,14 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
|
|
||||||
{/* 第三行:模板快捷入口水平滚动 */}
|
{/* 第三行:模板快捷入口水平滚动 */}
|
||||||
<div data-alt="template-quick-entries" className="relative pl-2">
|
<div data-alt="template-quick-entries" className="relative pl-2">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pr-6 py-1">
|
<div
|
||||||
|
ref={templateScrollRef}
|
||||||
|
className={`flex items-center gap-2 overflow-x-auto scrollbar-hide pr-6 py-1 cursor-grab select-none ${isTemplateDragging ? 'cursor-grabbing' : ''}`}
|
||||||
|
onMouseDown={handleTemplateMouseDown}
|
||||||
|
onMouseMove={handleTemplateMouseMove}
|
||||||
|
onMouseUp={handleTemplateMouseUp}
|
||||||
|
onMouseLeave={handleTemplateMouseUp}
|
||||||
|
>
|
||||||
{isTemplateLoading && (!templateStoryList || templateStoryList.length === 0) ? (
|
{isTemplateLoading && (!templateStoryList || templateStoryList.length === 0) ? (
|
||||||
// 骨架屏:若正在加载且没有数据
|
// 骨架屏:若正在加载且没有数据
|
||||||
Array.from({ length: 6 }).map((_, idx) => (
|
Array.from({ length: 6 }).map((_, idx) => (
|
||||||
@ -525,7 +578,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
<button
|
<button
|
||||||
key={tpl.id}
|
key={tpl.id}
|
||||||
data-alt={`template-chip-${tpl.id}`}
|
data-alt={`template-chip-${tpl.id}`}
|
||||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white text-xs transition-colors"
|
className="flex-shrink-0 px-3 py-1.5 select-none rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white text-xs transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// id 映射:优先使用模板的 id;若需要兼容 template_id,则传两者之一
|
// id 映射:优先使用模板的 id;若需要兼容 template_id,则传两者之一
|
||||||
setInitialTemplateId(tpl.id || (tpl as any).template_id);
|
setInitialTemplateId(tpl.id || (tpl as any).template_id);
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export const H5PhotoStoryDrawer = ({
|
|||||||
|
|
||||||
const { loadingText } = useLoadScriptText(isLoading);
|
const { loadingText } = useLoadScriptText(isLoading);
|
||||||
const [localLoading, setLocalLoading] = useState(0);
|
const [localLoading, setLocalLoading] = useState(0);
|
||||||
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
|
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_PORTRAIT");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const taskProgressRef = useRef(taskProgress);
|
const taskProgressRef = useRef(taskProgress);
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
|
|||||||
@ -79,7 +79,7 @@ export const H5TemplateDrawer = ({
|
|||||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||||
// 自由输入框布局
|
// 自由输入框布局
|
||||||
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
||||||
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
|
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_PORTRAIT");
|
||||||
// 顶部列表所在的实际滚动容器(外层 top-section 才是滚动容器)
|
// 顶部列表所在的实际滚动容器(外层 top-section 才是滚动容器)
|
||||||
const topSectionRef = useRef<HTMLDivElement | null>(null);
|
const topSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,8 @@ interface SmartChatBoxProps {
|
|||||||
onClearPreview?: () => void;
|
onClearPreview?: () => void;
|
||||||
setIsFocusChatInput?: (v: boolean) => void;
|
setIsFocusChatInput?: (v: boolean) => void;
|
||||||
aiEditingResult?: any;
|
aiEditingResult?: any;
|
||||||
|
/** 新消息回调:用于外层处理未展开时的气泡提示 */
|
||||||
|
onNewMessage?: (snippet: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageGroup {
|
interface MessageGroup {
|
||||||
@ -47,7 +49,8 @@ export default function SmartChatBox({
|
|||||||
previewVideoId,
|
previewVideoId,
|
||||||
onClearPreview,
|
onClearPreview,
|
||||||
setIsFocusChatInput,
|
setIsFocusChatInput,
|
||||||
aiEditingResult
|
aiEditingResult,
|
||||||
|
onNewMessage
|
||||||
}: SmartChatBoxProps) {
|
}: SmartChatBoxProps) {
|
||||||
// 消息列表引用
|
// 消息列表引用
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
@ -103,6 +106,23 @@ export default function SmartChatBox({
|
|||||||
onMessagesUpdate: handleMessagesUpdate
|
onMessagesUpdate: handleMessagesUpdate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听消息新增,向外层抛出前10个字符的文本片段
|
||||||
|
const prevLenRef = useRef<number>(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const len = messages.length;
|
||||||
|
if (len > prevLenRef.current && len > 0) {
|
||||||
|
const last = messages[len - 1];
|
||||||
|
// 提取第一个文本块
|
||||||
|
const textBlock = last.blocks.find(b => (b as any).type === 'text') as any;
|
||||||
|
const text = textBlock?.text || '';
|
||||||
|
if (text && onNewMessage) {
|
||||||
|
const snippet = text.slice(0, 40);
|
||||||
|
onNewMessage(snippet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevLenRef.current = len;
|
||||||
|
}, [messages, onNewMessage]);
|
||||||
|
|
||||||
// 监听智能剪辑结果,自动发送消息到聊天框
|
// 监听智能剪辑结果,自动发送消息到聊天框
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// if (aiEditingResult && isSmartChatBoxOpen) {
|
// if (aiEditingResult && isSmartChatBoxOpen) {
|
||||||
@ -179,7 +199,7 @@ export default function SmartChatBox({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Messages grouped by date */}
|
{/* Messages grouped by date */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pb-28">
|
||||||
{groupedMessages.map((group) => (
|
{groupedMessages.map((group) => (
|
||||||
<React.Fragment key={group.date}>
|
<React.Fragment key={group.date}>
|
||||||
<DateDivider timestamp={group.date} />
|
<DateDivider timestamp={group.date} />
|
||||||
|
|||||||
@ -61,10 +61,10 @@ export default function SigninPage() {
|
|||||||
|
|
||||||
if (isInitialLoading) {
|
if (isInitialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md space-y-6">
|
<div data-alt="signin-container" className="mx-auto w-full max-w-sm space-y-6">
|
||||||
<Card className="bg-transparent border-0 shadow-none">
|
<Card data-alt="loading-card" className="bg-transparent border-0 shadow-none">
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
<CardContent data-alt="loading-content" className="flex items-center justify-center py-12">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div data-alt="loading-indicator" className="flex items-center gap-2 text-muted-foreground">
|
||||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
@ -75,47 +75,53 @@ export default function SigninPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md space-y-6">
|
<div data-alt="signin-container" className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
|
||||||
{/* Signin status card */}
|
{/* Signin status card */}
|
||||||
<Card className="bg-transparent border-0 shadow-none">
|
<Card data-alt="signin-card" className="bg-transparent border-0 shadow-none">
|
||||||
<CardHeader className="text-center pb-4 pt-0">
|
<CardHeader data-alt="signin-header" className="text-center pb-4 pt-0">
|
||||||
<h1 className="text-3xl font-bold text-balance bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
<h1 data-alt="signin-title" className="text-2xl sm:text-3xl font-bold text-balance bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
||||||
Daily Sign-in
|
Daily Sign-in
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<p className="text-muted-foreground">Sign in to earn credits. Credits are valid for 7 days</p>
|
<p className="text-sm sm:text-base text-muted-foreground">Sign in to earn credits. Credits are valid for 7 days</p>
|
||||||
<div className="relative">
|
<div className="relative" data-alt="signin-help-wrapper">
|
||||||
<button
|
<button
|
||||||
|
data-alt="signin-help-button"
|
||||||
onMouseEnter={() => setShowTip(true)}
|
onMouseEnter={() => setShowTip(true)}
|
||||||
onMouseLeave={() => setShowTip(false)}
|
onMouseLeave={() => setShowTip(false)}
|
||||||
className="p-1 rounded-full hover:bg-muted/50 transition-colors"
|
onClick={() => setShowTip((v: boolean) => !v)}
|
||||||
|
className="p-2 rounded-full hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
<HelpCircle className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
{showTip && (
|
{showTip && (
|
||||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-80 p-3 bg-popover border rounded-lg shadow-lg z-10">
|
<div
|
||||||
|
data-alt="signin-help-popover"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-16 inset-x-4 sm:inset-auto sm:absolute sm:bottom-full sm:left-1/2 sm:-translate-x-1/2 sm:mb-2
|
||||||
|
w-[92vw] max-w-sm sm:w-80 p-3 bg-popover border rounded-lg shadow-lg z-50 max-h-[60vh] overflow-auto"
|
||||||
|
>
|
||||||
<div className="text-sm space-y-1 text-left">
|
<div className="text-sm space-y-1 text-left">
|
||||||
<p className="font-medium text-foreground">Sign-in Rules</p>
|
<p className="font-medium text-foreground">Sign-in Rules</p>
|
||||||
<p className="text-muted-foreground">• Daily sign-in earns 100 credits</p>
|
<p className="text-muted-foreground">• Daily sign-in earns 100 credits</p>
|
||||||
<p className="text-muted-foreground">• Credits are valid for 7 days</p>
|
<p className="text-muted-foreground">• Credits are valid for 7 days</p>
|
||||||
<p className="text-muted-foreground">• Expired credits will be automatically cleared</p>
|
<p className="text-muted-foreground">• Expired credits will be automatically cleared</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-popover"></div>
|
<div className="hidden sm:block absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-popover"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent data-alt="signin-content" className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<div className="text-center p-6 rounded-lg bg-gradient-to-br from-custom-blue/20 via-custom-purple/20 to-custom-blue/10 border border-custom-blue/30">
|
<div data-alt="credits-card" className="text-center p-4 sm:p-6 rounded-lg bg-gradient-to-br from-custom-blue/20 via-custom-purple/20 to-custom-blue/10 border border-custom-blue/30">
|
||||||
<div className="flex items-center justify-center gap-2 mb-2">
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
<Coins className="w-6 h-6 text-primary" />
|
<Coins className="w-6 h-6 text-primary" />
|
||||||
<span className="text-sm text-muted-foreground">Earned Credits</span>
|
<span className="text-sm text-muted-foreground">Earned Credits</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
<div className="text-3xl sm:text-4xl font-bold bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
||||||
{signinData.credits || 0}
|
{signinData.credits || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -124,9 +130,10 @@ export default function SigninPage() {
|
|||||||
|
|
||||||
{/* Sign-in button */}
|
{/* Sign-in button */}
|
||||||
<Button
|
<Button
|
||||||
|
data-alt="signin-button"
|
||||||
onClick={handleSignin}
|
onClick={handleSignin}
|
||||||
disabled={signinData.has_signin || isLoading}
|
disabled={signinData.has_signin || isLoading}
|
||||||
className="w-full h-12 text-lg font-semibold bg-gradient-to-r from-custom-blue to-custom-purple hover:from-custom-blue/90 hover:to-custom-purple/90 text-white"
|
className="w-full h-11 text-base sm:h-12 sm:text-lg font-semibold bg-gradient-to-r from-custom-blue to-custom-purple hover:from-custom-blue/90 hover:to-custom-purple/90 text-white"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
User,
|
User,
|
||||||
Sparkles,
|
Gift,
|
||||||
LogOut,
|
LogOut,
|
||||||
PanelsLeftBottom,
|
PanelsLeftBottom,
|
||||||
Bell,
|
Bell,
|
||||||
@ -261,7 +261,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
left: (pathname === "/" || !isDesktop) ? "0" : (collapsed ? "2.5rem" : "16rem")
|
left: (pathname === "/" || !isDesktop) ? "0" : (collapsed ? "2.5rem" : "16rem")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full flex items-center justify-between pr-4 md:px-4">
|
<div className={`h-full flex items-center justify-between pr-4 md:px-4 ${pathname !== "/" ? "px-4" : ""}`}>
|
||||||
<div className="flex items-center md:space-x-4">
|
<div className="flex items-center md:space-x-4">
|
||||||
{pathname === "/" && (
|
{pathname === "/" && (
|
||||||
<button
|
<button
|
||||||
@ -432,9 +432,17 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
{/* AI 积分 */}
|
{/* AI 积分 */}
|
||||||
<div className="flex flex-col items-center mb-3">
|
<div className="flex flex-col items-center mb-3">
|
||||||
<div className="flex items-center justify-center space-x-3 mb-2">
|
<div className="flex items-center justify-center space-x-3 mb-2">
|
||||||
<div className="p-2 rounded-full bg-white/10 backdrop-blur-sm">
|
<button
|
||||||
<Sparkles className="h-5 w-5 text-white" />
|
type="button"
|
||||||
</div>
|
onClick={() => router.push("/share")}
|
||||||
|
className="w-9 h-9 p-2 rounded-full bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-colors"
|
||||||
|
data-alt="share-entry-button"
|
||||||
|
title="Share"
|
||||||
|
>
|
||||||
|
<span className="inline-block motion-safe:animate-wiggle">
|
||||||
|
<Gift className="h-5 w-5 text-white" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<span className="text-white text-base font-semibold">
|
<span className="text-white text-base font-semibold">
|
||||||
{isLoadingSubscription
|
{isLoadingSubscription
|
||||||
? "Loading..."
|
? "Loading..."
|
||||||
@ -568,10 +576,8 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
data-alt="signin-modal"
|
data-alt="signin-modal"
|
||||||
>
|
>
|
||||||
<DialogTitle></DialogTitle>
|
<DialogTitle></DialogTitle>
|
||||||
<div className="p-4">
|
<SigninBox />
|
||||||
<SigninBox />
|
</DialogContent>
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,13 +5,14 @@ import { Loader2, Download } from 'lucide-react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import './style/create-to-video2.css';
|
import './style/create-to-video2.css';
|
||||||
|
|
||||||
import { getScriptEpisodeListNew } from "@/api/script_episode";
|
import { getScriptEpisodeListNew, MovieProject } from "@/api/script_episode";
|
||||||
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
||||||
import cover_image1 from '@/public/assets/cover_image3.jpg';
|
import cover_image1 from '@/public/assets/cover_image3.jpg';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Tooltip, Button } from 'antd';
|
import { Tooltip, Button } from 'antd';
|
||||||
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
||||||
import LazyLoad from "react-lazyload";
|
import Masonry from 'react-masonry-css';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -19,35 +20,75 @@ import LazyLoad from "react-lazyload";
|
|||||||
|
|
||||||
export default function CreateToVideo2() {
|
export default function CreateToVideo2() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [episodeList, setEpisodeList] = useState<any[]>([]);
|
const [episodeList, setEpisodeList] = useState<MovieProject[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [perPage] = useState(12);
|
const [perPage] = useState(28);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [isPreloading, setIsPreloading] = useState(false);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [userId, setUserId] = useState<number>(0);
|
const [userId, setUserId] = useState<number>(0);
|
||||||
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
||||||
|
const masonryRef = useRef<any>(null);
|
||||||
|
interface PreloadedData {
|
||||||
|
page: number;
|
||||||
|
data: {
|
||||||
|
code: number;
|
||||||
|
data: {
|
||||||
|
movie_projects: MovieProject[];
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const preloadedDataRef = useRef<PreloadedData | null>(null);
|
||||||
|
|
||||||
// 添加一个 ref 来跟踪当前正在加载的页码
|
// 添加一个 ref 来跟踪当前正在加载的页码
|
||||||
const loadingPageRef = useRef<number | null>(null);
|
const loadingPageRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 在客户端挂载后读取localStorage
|
// 在客户端挂载后读取localStorage
|
||||||
// 监听滚动事件,实现无限加载
|
// 预加载下一页数据
|
||||||
// 修改滚动处理函数,添加节流
|
const preloadNextPage = async (userId: number, page: number) => {
|
||||||
|
if (isPreloading || !hasMore || page > totalPages) return;
|
||||||
|
|
||||||
|
setIsPreloading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchEpisodeData(userId, page);
|
||||||
|
if (response.code === 0) {
|
||||||
|
preloadedDataRef.current = {
|
||||||
|
page,
|
||||||
|
data: response
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to preload next page:', error);
|
||||||
|
} finally {
|
||||||
|
setIsPreloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听滚动事件,实现无限加载和预加载
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return;
|
if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||||
if (scrollHeight - scrollTop - clientHeight < 100) {
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
// 直接使用 currentPage,不再使用 setCurrentPage 的回调
|
|
||||||
|
// 在滚动到 30% 时预加载下一页
|
||||||
|
// if (scrollPercentage > 0.30 && !isPreloading && currentPage < totalPages) {
|
||||||
|
// preloadNextPage(userId, currentPage + 1);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 在滚动到 70% 时加载下一页
|
||||||
|
if (scrollPercentage > 0.7) {
|
||||||
const nextPage = currentPage + 1;
|
const nextPage = currentPage + 1;
|
||||||
if (nextPage <= totalPages) {
|
if (nextPage <= totalPages) {
|
||||||
getEpisodeList(userId, nextPage, true);
|
getEpisodeList(userId, nextPage, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage]);
|
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage, isPreloading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@ -67,6 +108,16 @@ export default function CreateToVideo2() {
|
|||||||
}
|
}
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
// 获取剧集列表数据
|
||||||
|
const fetchEpisodeData = async (userId: number, page: number) => {
|
||||||
|
const params = {
|
||||||
|
user_id: String(userId),
|
||||||
|
page,
|
||||||
|
per_page: perPage
|
||||||
|
};
|
||||||
|
return await getScriptEpisodeListNew(params);
|
||||||
|
};
|
||||||
|
|
||||||
// 修改获取剧集列表函数
|
// 修改获取剧集列表函数
|
||||||
const getEpisodeList = async (userId: number, page: number = 1, loadMore: boolean = false) => {
|
const getEpisodeList = async (userId: number, page: number = 1, loadMore: boolean = false) => {
|
||||||
// 检查是否正在加载该页
|
// 检查是否正在加载该页
|
||||||
@ -83,13 +134,15 @@ export default function CreateToVideo2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = {
|
let episodeListResponse;
|
||||||
user_id: String(userId),
|
|
||||||
page,
|
// 如果有预加载的数据且页码匹配,直接使用
|
||||||
per_page: perPage
|
if (preloadedDataRef.current && preloadedDataRef.current.page === page) {
|
||||||
};
|
episodeListResponse = preloadedDataRef.current.data;
|
||||||
|
preloadedDataRef.current = null;
|
||||||
const episodeListResponse = await getScriptEpisodeListNew(params);
|
} else {
|
||||||
|
episodeListResponse = await fetchEpisodeData(userId, page);
|
||||||
|
}
|
||||||
|
|
||||||
if (episodeListResponse.code === 0) {
|
if (episodeListResponse.code === 0) {
|
||||||
const { movie_projects, total_pages } = episodeListResponse.data;
|
const { movie_projects, total_pages } = episodeListResponse.data;
|
||||||
@ -109,6 +162,11 @@ export default function CreateToVideo2() {
|
|||||||
setTotalPages(total_pages);
|
setTotalPages(total_pages);
|
||||||
setHasMore(page < total_pages);
|
setHasMore(page < total_pages);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
|
|
||||||
|
// 预加载下一页数据
|
||||||
|
// if (page < total_pages && !isPreloading) {
|
||||||
|
// preloadNextPage(userId, page + 1);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -188,38 +246,72 @@ export default function CreateToVideo2() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderProjectCard = (project: any) => {
|
// 监听窗口大小变化,触发 Masonry 重排
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = debounce(() => {
|
||||||
|
if (masonryRef.current?.recomputeCellPositions) {
|
||||||
|
masonryRef.current.recomputeCellPositions();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => {
|
||||||
|
handleResize.cancel();
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderProjectCard = (project: MovieProject): JSX.Element => {
|
||||||
|
// 根据 aspect_ratio 计算纵横比
|
||||||
|
const getAspectRatio = () => {
|
||||||
|
switch (project.aspect_ratio) {
|
||||||
|
case "VIDEO_ASPECT_RATIO_LANDSCAPE":
|
||||||
|
return 16 / 9; // 横屏 16:9
|
||||||
|
case "VIDEO_ASPECT_RATIO_PORTRAIT":
|
||||||
|
return 9 / 16; // 竖屏 9:16
|
||||||
|
default:
|
||||||
|
return 16 / 9; // 默认横屏
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const aspectRatio = getAspectRatio();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyLoad key={project.project_id} once>
|
|
||||||
<div
|
<div
|
||||||
key={project.project_id}
|
key={project.project_id}
|
||||||
className="group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
|
className="relative group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
|
||||||
onMouseEnter={() => handleMouseEnter(project.project_id)}
|
onMouseEnter={() => handleMouseEnter(project.project_id)}
|
||||||
onMouseLeave={() => handleMouseLeave(project.project_id)}
|
onMouseLeave={() => handleMouseLeave(project.project_id)}
|
||||||
data-alt="project-card"
|
data-alt="project-card"
|
||||||
>
|
>
|
||||||
{/* 视频/图片区域 */}
|
{/* 视频/图片区域(使用 aspect_ratio 预设高度) */}
|
||||||
<div className="relative w-full pb-[56.25%]" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{
|
||||||
|
aspectRatio: aspectRatio
|
||||||
|
}}
|
||||||
|
onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}
|
||||||
|
>
|
||||||
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
|
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
|
||||||
<video
|
<video
|
||||||
ref={(el) => setVideoRef(project.project_id, el)}
|
ref={(el) => setVideoRef(project.project_id, el)}
|
||||||
src={project.final_video_url || project.final_simple_video_url || project.video_urls}
|
src={project.final_video_url || project.final_simple_video_url || project.video_urls || ''}
|
||||||
className="absolute inset-0 w-full h-full object-contain group-hover:scale-105 transition-transform duration-500"
|
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="auto"
|
||||||
poster={
|
poster={
|
||||||
getFirstFrame(project.final_video_url || project.final_simple_video_url || project.video_urls)
|
getFirstFrame(project.final_video_url || project.final_simple_video_url || project.video_urls || '', 300)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 w-full h-full bg-contain bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${cover_image1.src})`,
|
backgroundImage: `url(${cover_image1.src})`,
|
||||||
}}
|
}}
|
||||||
|
data-alt="cover-image"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -247,7 +339,7 @@ export default function CreateToVideo2() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部信息 */}
|
{/* 底部信息 */}
|
||||||
<div className="p-4 group">
|
<div className="p-2 group absolute bottom-0 left-0 right-0 bg-black/10 backdrop-blur-lg rounded-b-lg hidden group-hover:block">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-medium text-white line-clamp-1">
|
<h2 className="text-sm font-medium text-white line-clamp-1">
|
||||||
@ -265,7 +357,7 @@ export default function CreateToVideo2() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</LazyLoad>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -282,9 +374,20 @@ 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 gap-6">
|
{(() => {
|
||||||
{episodeList.map(renderProjectCard)}
|
const masonryBreakpoints = { default: 5, 1024: 2, 640: 1 };
|
||||||
</div>
|
return (
|
||||||
|
<Masonry
|
||||||
|
ref={masonryRef}
|
||||||
|
breakpointCols={masonryBreakpoints}
|
||||||
|
className="flex -ml-2"
|
||||||
|
columnClassName="pl-2 space-y-2"
|
||||||
|
data-alt="masonry-grid"
|
||||||
|
>
|
||||||
|
{episodeList.map(renderProjectCard)}
|
||||||
|
</Masonry>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 加载更多指示器 */}
|
{/* 加载更多指示器 */}
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
|
|||||||
@ -276,7 +276,7 @@ export function HomePage2() {
|
|||||||
</LazyLoad>
|
</LazyLoad>
|
||||||
{/* 动态锚点:来源于服务端 homeTab 配置,title 作为锚点与标题 */}
|
{/* 动态锚点:来源于服务端 homeTab 配置,title 作为锚点与标题 */}
|
||||||
{homeTabs.map((tab) => (
|
{homeTabs.map((tab) => (
|
||||||
<div key={tab.title} data-test={JSON.stringify(tab)} data-alt={`anchor-${tab.title.toLowerCase()}`} ref={(el) => (sectionRefs.current as any)[tab.title.toLowerCase()] = el}>
|
<div key={tab.title} data-alt={`anchor-${tab.title.toLowerCase()}`} ref={(el) => (sectionRefs.current as any)[tab.title.toLowerCase()] = el}>
|
||||||
<VideoCoverflow title={tab.title} subtitle={tab.subtitle} videos={tab.videos} />
|
<VideoCoverflow title={tab.title} subtitle={tab.subtitle} videos={tab.videos} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { MediaViewer } from "./work-flow/media-viewer";
|
|||||||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
||||||
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
||||||
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
||||||
import { Bot, TestTube } from "lucide-react";
|
import { Bot, TestTube, MessageCircle } from "lucide-react";
|
||||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
@ -48,6 +48,8 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
|
||||||
const [activeEditTab, setActiveEditTab] = React.useState('1');
|
const [activeEditTab, setActiveEditTab] = React.useState('1');
|
||||||
const [isSmartChatBoxOpen, setIsSmartChatBoxOpen] = React.useState(true);
|
const [isSmartChatBoxOpen, setIsSmartChatBoxOpen] = React.useState(true);
|
||||||
|
const [chatTip, setChatTip] = React.useState<string | null>(null);
|
||||||
|
const [hasUnread, setHasUnread] = React.useState(false);
|
||||||
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
|
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
|
||||||
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
|
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
|
||||||
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
|
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
|
||||||
@ -617,15 +619,30 @@ Please process this video editing request.`;
|
|||||||
|
|
||||||
{/* 智能对话按钮 */}
|
{/* 智能对话按钮 */}
|
||||||
<div
|
<div
|
||||||
className="fixed right-[1rem] bottom-[10rem] z-[49]"
|
className={`fixed right-[1rem] z-[49] ${isMobile ? 'bottom-[2rem]' : 'bottom-[10rem]'}`}
|
||||||
>
|
>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<GlassIconButton
|
<div className="relative">
|
||||||
icon={Bot}
|
{(!isSmartChatBoxOpen && chatTip) && (
|
||||||
size='md'
|
<div className="absolute -top-8 right-0 bg-black/80 text-white text-xs px-2 py-1 rounded-md whitespace-nowrap bg-custom-blue/30">
|
||||||
onClick={() => setIsSmartChatBoxOpen(true)}
|
{chatTip}
|
||||||
className="backdrop-blur-lg"
|
</div>
|
||||||
/>
|
)}
|
||||||
|
{/* 红点徽标 */}
|
||||||
|
{(!isSmartChatBoxOpen && hasUnread) && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border border-white" />
|
||||||
|
)}
|
||||||
|
<GlassIconButton
|
||||||
|
icon={MessageCircle}
|
||||||
|
size='md'
|
||||||
|
onClick={() => {
|
||||||
|
setIsSmartChatBoxOpen(true);
|
||||||
|
setChatTip(null);
|
||||||
|
setHasUnread(false);
|
||||||
|
}}
|
||||||
|
className="backdrop-blur-lg bg-custom-purple/80 border-transparent hover:bg-custom-purple/80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title="Open chat" placement="left">
|
<Tooltip title="Open chat" placement="left">
|
||||||
<GlassIconButton
|
<GlassIconButton
|
||||||
@ -641,6 +658,7 @@ Please process this video editing request.`;
|
|||||||
{/* 智能对话弹窗 */}
|
{/* 智能对话弹窗 */}
|
||||||
<Drawer
|
<Drawer
|
||||||
width={isMobile ? '100vw' : '25%'}
|
width={isMobile ? '100vw' : '25%'}
|
||||||
|
height={isMobile ? 'auto' : ''}
|
||||||
placement={isMobile ? 'bottom' : 'right'}
|
placement={isMobile ? 'bottom' : 'right'}
|
||||||
closable={false}
|
closable={false}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
@ -648,9 +666,9 @@ Please process this video editing request.`;
|
|||||||
getContainer={false}
|
getContainer={false}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
mask={false}
|
mask={false}
|
||||||
zIndex={49}
|
zIndex={60}
|
||||||
rootClassName="outline-none"
|
rootClassName="outline-none"
|
||||||
className="backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl"
|
className="backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl max-h-[90vh]"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
...(isMobile
|
...(isMobile
|
||||||
@ -676,6 +694,14 @@ Please process this video editing request.`;
|
|||||||
previewVideoUrl={previewVideoUrl}
|
previewVideoUrl={previewVideoUrl}
|
||||||
previewVideoId={previewVideoId}
|
previewVideoId={previewVideoId}
|
||||||
setIsFocusChatInput={setIsFocusChatInput}
|
setIsFocusChatInput={setIsFocusChatInput}
|
||||||
|
onNewMessage={(snippet) => {
|
||||||
|
if (!isSmartChatBoxOpen && snippet) {
|
||||||
|
setChatTip(snippet);
|
||||||
|
setHasUnread(true);
|
||||||
|
// 5秒后自动消失
|
||||||
|
setTimeout(() => setChatTip(null), 5000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClearPreview={() => {
|
onClearPreview={() => {
|
||||||
setPreviewVideoUrl(null);
|
setPreviewVideoUrl(null);
|
||||||
setPreviewVideoId(null);
|
setPreviewVideoId(null);
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Carousel } from 'antd';
|
import { Carousel } from 'antd';
|
||||||
import type { CarouselRef } from 'antd/es/carousel';
|
import type { CarouselRef } from 'antd/es/carousel';
|
||||||
import { Play, Pause, Scissors, MessageCircleMore, Download, ArrowDownWideNarrow, RotateCcw, Navigation } from 'lucide-react';
|
import { Play, Pause, FeatherIcon, MessageCircleMore, Download, ArrowDownWideNarrow, RotateCcw, Navigation } from 'lucide-react';
|
||||||
import { TaskObject } from '@/api/DTO/movieEdit';
|
import { TaskObject } from '@/api/DTO/movieEdit';
|
||||||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||||||
import ScriptLoading from './script-loading';
|
import ScriptLoading from './script-loading';
|
||||||
@ -395,15 +395,15 @@ export function H5MediaViewer({
|
|||||||
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
|
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
|
||||||
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
|
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
|
||||||
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
|
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
|
||||||
{/* 全局固定操作区(右上角)视频暂停时展示 */}
|
{/* 全局固定操作区(右下角)视频暂停时展示 */}
|
||||||
{(stage === 'video' || stage === 'final_video') && !isPlaying && (
|
{(stage === 'video' || stage === 'final_video') && !isPlaying && (
|
||||||
<div data-alt="global-video-actions" className="absolute top-2 right-6 z-[60] flex items-center gap-2">
|
<div data-alt="global-video-actions" className="absolute bottom-0 right-4 z-[60] flex flex-col items-center gap-2">
|
||||||
{stage === 'video' && (
|
{stage === 'video' && (
|
||||||
<>
|
<>
|
||||||
<GlassIconButton
|
<GlassIconButton
|
||||||
data-alt="edit-with-chat-button"
|
data-alt="edit-with-chat-button"
|
||||||
className="w-8 h-8 bg-gradient-to-br from-blue-500/80 to-blue-600/80 backdrop-blur-xl border border-blue-400/30 rounded-full flex items-center justify-center hover:from-blue-400/80 hover:to-blue-500/80 transition-all"
|
className="w-8 h-8 bg-custom-purple backdrop-blur-xl rounded-full flex items-center justify-center transition-all"
|
||||||
icon={MessageCircleMore}
|
icon={FeatherIcon}
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="edit-with-chat"
|
aria-label="edit-with-chat"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -416,7 +416,7 @@ export function H5MediaViewer({
|
|||||||
/>
|
/>
|
||||||
<GlassIconButton
|
<GlassIconButton
|
||||||
data-alt="download-all-button"
|
data-alt="download-all-button"
|
||||||
className="w-8 h-8 bg-gradient-to-br from-purple-500/80 to-purple-600/80 backdrop-blur-xl border border-purple-400/30 rounded-full flex items-center justify-center hover:from-purple-400/80 hover:to-purple-500/80 transition-all"
|
className="w-8 h-8 bg-gradient-to-br from-purple-500/80 to-purple-600/80 backdrop-blur-xl rounded-full flex items-center justify-center hover:from-purple-400/80 hover:to-purple-500/80 transition-all"
|
||||||
icon={ArrowDownWideNarrow}
|
icon={ArrowDownWideNarrow}
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="download-all"
|
aria-label="download-all"
|
||||||
@ -430,7 +430,7 @@ export function H5MediaViewer({
|
|||||||
return status === 1 ? (
|
return status === 1 ? (
|
||||||
<GlassIconButton
|
<GlassIconButton
|
||||||
data-alt="download-current-button"
|
data-alt="download-current-button"
|
||||||
className="w-8 h-8 bg-gradient-to-br from-amber-500/80 to-yellow-600/80 backdrop-blur-xl border border-amber-400/30 rounded-full flex items-center justify-center hover:from-amber-400/80 hover:to-yellow-500/80 transition-all"
|
className="w-8 h-8 bg-gradient-to-br from-purple-600/80 to-purple-700/80 backdrop-blur-xl rounded-full flex items-center justify-center transition-all"
|
||||||
icon={Download}
|
icon={Download}
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="download-current"
|
aria-label="download-current"
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { TaskObject } from '@/api/DTO/movieEdit'
|
import { TaskObject } from '@/api/DTO/movieEdit'
|
||||||
import { motion } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Heart, Camera, Film, Scissors, type LucideIcon } from 'lucide-react'
|
import { Heart, Camera, Film, Scissors, type LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
interface H5TaskInfoProps {
|
interface H5TaskInfoProps {
|
||||||
@ -94,6 +94,34 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
|||||||
return null
|
return null
|
||||||
}, [selectedView, taskObject, displayCurrent, total])
|
}, [selectedView, taskObject, displayCurrent, total])
|
||||||
|
|
||||||
|
/** 阶段图标(H5 精简版) */
|
||||||
|
const StageIcon = useMemo(() => {
|
||||||
|
const Icon = stageIconMap[currentStage].icon
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
data-alt="stage-icon"
|
||||||
|
className="relative"
|
||||||
|
initial={{ opacity: 0, x: -8, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="rounded-full p-1"
|
||||||
|
animate={{
|
||||||
|
rotate: [0, 360],
|
||||||
|
scale: [1, 1.1, 1],
|
||||||
|
transition: {
|
||||||
|
rotate: { duration: 3, repeat: Infinity, ease: 'linear' },
|
||||||
|
scale: { duration: 1.6, repeat: Infinity, ease: 'easeInOut' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" style={{ color: stageColor }} />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}, [currentStage, stageColor])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-alt="h5-header"
|
data-alt="h5-header"
|
||||||
@ -101,9 +129,10 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-alt="h5-header-bar"
|
data-alt="h5-header-bar"
|
||||||
className="flex items-start justify-between"
|
className="flex items-start gap-3"
|
||||||
>
|
>
|
||||||
<div data-alt="title-area" className="flex flex-col min-w-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg py-4">
|
{/* 左侧标题区域 */}
|
||||||
|
<div data-alt="title-area" className="flex-1 min-w-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg py-4 px-4">
|
||||||
<h1
|
<h1
|
||||||
data-alt="title"
|
data-alt="title"
|
||||||
className="text-white text-lg font-bold"
|
className="text-white text-lg font-bold"
|
||||||
@ -116,7 +145,58 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧状态区域 */}
|
||||||
|
<div data-alt="status-area" className="flex-shrink-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg py-4 px-4 max-w-[200px]">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{currentLoadingText && currentLoadingText !== 'Task completed' && (
|
||||||
|
<motion.div
|
||||||
|
key={currentLoadingText}
|
||||||
|
data-alt="status-line"
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
initial={{ opacity: 0, x: 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 10 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{StageIcon}
|
||||||
|
</div>
|
||||||
|
<div className="relative text-center">
|
||||||
|
{/* 背景流光 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-cyan-400 to-purple-400 blur-[1px]"
|
||||||
|
animate={{
|
||||||
|
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
|
||||||
|
transition: { duration: 2, repeat: Infinity, ease: 'linear' }
|
||||||
|
}}
|
||||||
|
style={{ backgroundSize: '200% 200%' }}
|
||||||
|
>
|
||||||
|
<span className="text-xs leading-tight break-words">{currentLoadingText}</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 主文字轻微律动 */}
|
||||||
|
<motion.div
|
||||||
|
className="relative z-10"
|
||||||
|
animate={{ scale: [1, 1.02, 1] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 底部装饰线 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute -bottom-0.5 left-1/2 transform -translate-x-1/2 h-0.5 w-8"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${stageColor}, rgb(34 211 238), rgb(168 85 247))`
|
||||||
|
}}
|
||||||
|
animate={{ width: ['0%', '100%', '0%'] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { Button, Tooltip } from 'antd';
|
|||||||
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
||||||
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
|
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
|
||||||
import { EditPoint as EditPointType } from './video-edit/types';
|
import { EditPoint as EditPointType } from './video-edit/types';
|
||||||
|
import { isVideoModificationEnabled } from '@/lib/server-config';
|
||||||
|
|
||||||
interface MediaViewerProps {
|
interface MediaViewerProps {
|
||||||
taskObject: TaskObject;
|
taskObject: TaskObject;
|
||||||
@ -78,6 +79,8 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
||||||
const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false);
|
const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false);
|
||||||
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
|
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
|
||||||
|
// 控制钢笔图标显示的状态 - 参考谷歌登录按钮的实现
|
||||||
|
const [showVideoModification, setShowVideoModification] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSmartChatBoxOpen) {
|
if (isSmartChatBoxOpen) {
|
||||||
@ -89,6 +92,33 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
}
|
}
|
||||||
}, [isSmartChatBoxOpen])
|
}, [isSmartChatBoxOpen])
|
||||||
|
|
||||||
|
// 检查视频修改功能是否启用 - 参考谷歌登录按钮的实现
|
||||||
|
useEffect(() => {
|
||||||
|
const checkVideoModificationStatus = async () => {
|
||||||
|
try {
|
||||||
|
console.log('🔍 MediaViewer:开始检查视频修改功能状态...');
|
||||||
|
const enabled = await isVideoModificationEnabled();
|
||||||
|
console.log('📋 MediaViewer:视频修改功能启用状态:', enabled);
|
||||||
|
setShowVideoModification(enabled);
|
||||||
|
console.log('📋 MediaViewer:设置showVideoModification状态为:', enabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ MediaViewer:Failed to check video modification status:", error);
|
||||||
|
setShowVideoModification(false); // 出错时默认不显示
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkVideoModificationStatus();
|
||||||
|
}, []); // 只在组件挂载时执行一次
|
||||||
|
|
||||||
|
// 调试:监控钢笔图标显示状态
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('🔧 MediaViewer状态更新:', {
|
||||||
|
enableVideoEdit,
|
||||||
|
showVideoModification,
|
||||||
|
shouldShowPenIcon: enableVideoEdit && showVideoModification
|
||||||
|
});
|
||||||
|
}, [enableVideoEdit, showVideoModification]);
|
||||||
|
|
||||||
// 音量控制函数
|
// 音量控制函数
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
setUserHasInteracted(true);
|
setUserHasInteracted(true);
|
||||||
@ -526,17 +556,20 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
<div className="absolute top-4 right-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
|
<div className="absolute top-4 right-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
|
||||||
right: toosBtnRight
|
right: toosBtnRight
|
||||||
}}>
|
}}>
|
||||||
{/* 视频编辑模式切换按钮 - 临时注释 */}
|
{/* 视频编辑模式切换按钮 - 通过服务器配置控制显示 */}
|
||||||
{/* {enableVideoEdit && (
|
{enableVideoEdit && showVideoModification && (
|
||||||
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
|
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
|
||||||
<GlassIconButton
|
<GlassIconButton
|
||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => setIsVideoEditMode(!isVideoEditMode)}
|
onClick={() => {
|
||||||
|
console.log('🖊️ 钢笔图标被点击,切换编辑模式:', !isVideoEditMode);
|
||||||
|
setIsVideoEditMode(!isVideoEditMode);
|
||||||
|
}}
|
||||||
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
|
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)} */}
|
)}
|
||||||
{/* 添加到chat去编辑 按钮 */}
|
{/* 添加到chat去编辑 按钮 */}
|
||||||
<Tooltip placement="top" title="Edit video with chat">
|
<Tooltip placement="top" title="Edit video with chat">
|
||||||
<GlassIconButton icon={MessageCircleMore} size='sm' onClick={() => {
|
<GlassIconButton icon={MessageCircleMore} size='sm' onClick={() => {
|
||||||
|
|||||||
@ -6,6 +6,13 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ConnectionPathParams, InputBoxPosition } from './types';
|
import { ConnectionPathParams, InputBoxPosition } from './types';
|
||||||
|
import {
|
||||||
|
CONNECTION_STYLE,
|
||||||
|
ARROW_GEOMETRY,
|
||||||
|
calculateArrowGeometry,
|
||||||
|
calculateCurvePath as calculateUnifiedCurvePath,
|
||||||
|
getConnectionAnimationConfig
|
||||||
|
} from './connection-config';
|
||||||
|
|
||||||
interface EditConnectionProps {
|
interface EditConnectionProps {
|
||||||
/** 起始点坐标(编辑点位置) */
|
/** 起始点坐标(编辑点位置) */
|
||||||
@ -95,7 +102,8 @@ export function calculateInputPosition(
|
|||||||
direction = 'right';
|
direction = 'right';
|
||||||
inputX = pointX + connectionLength;
|
inputX = pointX + connectionLength;
|
||||||
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
|
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
|
||||||
connectionEndX = inputX;
|
// 箭头指向输入框左边缘的中心
|
||||||
|
connectionEndX = inputX - 8; // 向内偏移8px,指向输入框内部
|
||||||
connectionEndY = inputY + inputHeight / 2;
|
connectionEndY = inputY + inputHeight / 2;
|
||||||
}
|
}
|
||||||
// 其次选择左侧
|
// 其次选择左侧
|
||||||
@ -103,7 +111,8 @@ export function calculateInputPosition(
|
|||||||
direction = 'left';
|
direction = 'left';
|
||||||
inputX = pointX - connectionLength - inputWidth;
|
inputX = pointX - connectionLength - inputWidth;
|
||||||
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
|
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
|
||||||
connectionEndX = inputX + inputWidth;
|
// 箭头指向输入框右边缘的中心
|
||||||
|
connectionEndX = inputX + inputWidth + 8; // 向内偏移8px,指向输入框内部
|
||||||
connectionEndY = inputY + inputHeight / 2;
|
connectionEndY = inputY + inputHeight / 2;
|
||||||
}
|
}
|
||||||
// 然后选择下方
|
// 然后选择下方
|
||||||
@ -111,23 +120,26 @@ export function calculateInputPosition(
|
|||||||
direction = 'bottom';
|
direction = 'bottom';
|
||||||
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
|
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
|
||||||
inputY = pointY + connectionLength;
|
inputY = pointY + connectionLength;
|
||||||
|
// 箭头指向输入框上边缘的中心
|
||||||
connectionEndX = inputX + inputWidth / 2;
|
connectionEndX = inputX + inputWidth / 2;
|
||||||
connectionEndY = inputY;
|
connectionEndY = inputY - 8; // 向内偏移8px,指向输入框内部
|
||||||
}
|
}
|
||||||
// 最后选择上方
|
// 最后选择上方
|
||||||
else if (spaceTop >= inputHeight + connectionLength + margin) {
|
else if (spaceTop >= inputHeight + connectionLength + margin) {
|
||||||
direction = 'top';
|
direction = 'top';
|
||||||
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
|
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
|
||||||
inputY = pointY - connectionLength - inputHeight;
|
inputY = pointY - connectionLength - inputHeight;
|
||||||
|
// 箭头指向输入框下边缘的中心
|
||||||
connectionEndX = inputX + inputWidth / 2;
|
connectionEndX = inputX + inputWidth / 2;
|
||||||
connectionEndY = inputY + inputHeight;
|
connectionEndY = inputY + inputHeight + 8; // 向内偏移8px,指向输入框内部
|
||||||
}
|
}
|
||||||
// 如果空间不足,强制放在右侧并调整位置
|
// 如果空间不足,强制放在右侧并调整位置
|
||||||
else {
|
else {
|
||||||
direction = 'right';
|
direction = 'right';
|
||||||
inputX = Math.min(containerWidth - inputWidth - margin, pointX + 40);
|
inputX = Math.min(containerWidth - inputWidth - margin, pointX + 40);
|
||||||
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
|
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
|
||||||
connectionEndX = inputX;
|
// 箭头指向输入框左边缘的中心
|
||||||
|
connectionEndX = inputX - 8; // 向内偏移8px,指向输入框内部
|
||||||
connectionEndY = inputY + inputHeight / 2;
|
connectionEndY = inputY + inputHeight / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,80 +162,30 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
|
|||||||
curvature = 0.3,
|
curvature = 0.3,
|
||||||
animated = true
|
animated = true
|
||||||
}) => {
|
}) => {
|
||||||
|
// 使用统一的样式配置
|
||||||
const {
|
const {
|
||||||
color = 'rgba(255, 255, 255, 0.9)', // White color to match the reference image
|
color = CONNECTION_STYLE.color,
|
||||||
strokeWidth = 2,
|
strokeWidth = CONNECTION_STYLE.strokeWidth,
|
||||||
dashArray = '8,4' // Dashed line to match the reference image
|
dashArray = CONNECTION_STYLE.dashArray
|
||||||
} = style;
|
} = style;
|
||||||
|
|
||||||
// 计算箭头几何参数
|
// 使用统一的箭头几何计算
|
||||||
const arrowSize = 8;
|
const arrowGeometry = useMemo(() =>
|
||||||
const arrowHalfHeight = 4;
|
calculateArrowGeometry(startPoint, endPoint),
|
||||||
|
[startPoint, endPoint]
|
||||||
|
);
|
||||||
|
|
||||||
// 计算连接方向和角度
|
// 使用统一的路径计算
|
||||||
const connectionVector = useMemo(() => {
|
|
||||||
const dx = endPoint.x - startPoint.x;
|
|
||||||
const dy = endPoint.y - startPoint.y;
|
|
||||||
const length = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
return {
|
|
||||||
dx: dx / length,
|
|
||||||
dy: dy / length,
|
|
||||||
angle: Math.atan2(dy, dx)
|
|
||||||
};
|
|
||||||
}, [startPoint, endPoint]);
|
|
||||||
|
|
||||||
// 计算箭头的正确位置和线条终点
|
|
||||||
const arrowGeometry = useMemo(() => {
|
|
||||||
const { dx, dy, angle } = connectionVector;
|
|
||||||
|
|
||||||
// 箭头尖端位置(原endPoint)
|
|
||||||
const arrowTip = { x: endPoint.x, y: endPoint.y };
|
|
||||||
|
|
||||||
// 箭头底部中心点(线条应该连接到这里)
|
|
||||||
const arrowBase = {
|
|
||||||
x: endPoint.x - dx * arrowSize,
|
|
||||||
y: endPoint.y - dy * arrowSize
|
|
||||||
};
|
|
||||||
|
|
||||||
// 计算箭头三角形的三个顶点
|
|
||||||
const perpX = -dy; // 垂直向量X
|
|
||||||
const perpY = dx; // 垂直向量Y
|
|
||||||
|
|
||||||
const arrowPoints = [
|
|
||||||
arrowTip, // 尖端
|
|
||||||
{
|
|
||||||
x: arrowBase.x + perpX * arrowHalfHeight,
|
|
||||||
y: arrowBase.y + perpY * arrowHalfHeight
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: arrowBase.x - perpX * arrowHalfHeight,
|
|
||||||
y: arrowBase.y - perpY * arrowHalfHeight
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
tip: arrowTip,
|
|
||||||
base: arrowBase,
|
|
||||||
points: arrowPoints,
|
|
||||||
angle
|
|
||||||
};
|
|
||||||
}, [endPoint, connectionVector, arrowSize, arrowHalfHeight]);
|
|
||||||
|
|
||||||
// 计算路径(线条终止于箭头底部中心)
|
|
||||||
const path = useMemo(() =>
|
const path = useMemo(() =>
|
||||||
calculateCurvePath({
|
calculateUnifiedCurvePath(startPoint, arrowGeometry.center, containerSize),
|
||||||
start: startPoint,
|
[startPoint, arrowGeometry.center, containerSize]
|
||||||
end: arrowGeometry.base, // 连接到箭头底部中心而不是尖端
|
);
|
||||||
containerSize,
|
|
||||||
curvature
|
|
||||||
}), [startPoint, arrowGeometry.base, containerSize, curvature]);
|
|
||||||
|
|
||||||
// 计算路径长度用于动画
|
// 获取统一的动画配置
|
||||||
const pathLength = useMemo(() => {
|
const animationConfig = useMemo(() =>
|
||||||
const dx = arrowGeometry.base.x - startPoint.x;
|
getConnectionAnimationConfig(animated),
|
||||||
const dy = arrowGeometry.base.y - startPoint.y;
|
[animated]
|
||||||
return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20%
|
);
|
||||||
}, [startPoint, arrowGeometry.base]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@ -232,7 +194,7 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
|
|||||||
height={containerSize.height}
|
height={containerSize.height}
|
||||||
style={{ zIndex: 10 }}
|
style={{ zIndex: 10 }}
|
||||||
>
|
>
|
||||||
{/* Curved dashed line - properly aligned to arrow base center */}
|
{/* 统一的虚线连接线 - 精确连接到箭头中心 */}
|
||||||
<motion.path
|
<motion.path
|
||||||
d={path}
|
d={path}
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -241,44 +203,23 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
|
|||||||
strokeDasharray={dashArray}
|
strokeDasharray={dashArray}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
initial={animated ? {
|
initial={animationConfig.line.initial}
|
||||||
pathLength: 0,
|
animate={animationConfig.line.animate}
|
||||||
opacity: 0
|
transition={animationConfig.line.transition}
|
||||||
} : {}}
|
|
||||||
animate={animated ? {
|
|
||||||
pathLength: 1,
|
|
||||||
opacity: 1
|
|
||||||
} : {}}
|
|
||||||
transition={animated ? {
|
|
||||||
pathLength: { duration: 0.6, ease: "easeInOut" },
|
|
||||||
opacity: { duration: 0.3 }
|
|
||||||
} : {}}
|
|
||||||
style={{
|
style={{
|
||||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
filter: CONNECTION_STYLE.dropShadow
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Properly aligned arrow head with geometric precision */}
|
{/* 几何精确的箭头 - 与连接线完美对齐 */}
|
||||||
<motion.polygon
|
<motion.polygon
|
||||||
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
|
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
|
||||||
fill={color}
|
fill={color}
|
||||||
initial={animated ? {
|
initial={animationConfig.arrow.initial}
|
||||||
scale: 0,
|
animate={animationConfig.arrow.animate}
|
||||||
opacity: 0
|
transition={animationConfig.arrow.transition}
|
||||||
} : {}}
|
|
||||||
animate={animated ? {
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1
|
|
||||||
} : {}}
|
|
||||||
transition={animated ? {
|
|
||||||
delay: 0.4,
|
|
||||||
duration: 0.3,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 25
|
|
||||||
} : {}}
|
|
||||||
style={{
|
style={{
|
||||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
filter: CONNECTION_STYLE.dropShadow
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,12 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { EditPoint as EditPointType, EditPointStatus } from './types';
|
import { EditPoint as EditPointType, EditPointStatus } from './types';
|
||||||
|
import {
|
||||||
|
CONNECTION_STYLE,
|
||||||
|
calculateArrowGeometry,
|
||||||
|
calculateCurvePath,
|
||||||
|
getConnectionAnimationConfig
|
||||||
|
} from './connection-config';
|
||||||
|
|
||||||
interface EditDescriptionProps {
|
interface EditDescriptionProps {
|
||||||
/** 编辑点数据 */
|
/** 编辑点数据 */
|
||||||
@ -42,25 +48,31 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
|
|||||||
y: (editPoint.position.y / 100) * containerSize.height
|
y: (editPoint.position.y / 100) * containerSize.height
|
||||||
}), [editPoint.position, containerSize]);
|
}), [editPoint.position, containerSize]);
|
||||||
|
|
||||||
// 计算连接线路径
|
// 使用统一的连接线几何计算
|
||||||
const connectionPath = useMemo(() => {
|
const connectionGeometry = useMemo(() => {
|
||||||
const startX = editPointPosition.x;
|
const startPoint = { x: editPointPosition.x, y: editPointPosition.y };
|
||||||
const startY = editPointPosition.y;
|
const endPoint = { x: connectionEnd.x, y: connectionEnd.y };
|
||||||
const endX = connectionEnd.x;
|
|
||||||
const endY = connectionEnd.y;
|
|
||||||
|
|
||||||
// 计算控制点,创建优雅的弧线
|
// 使用统一的箭头几何计算
|
||||||
const deltaX = endX - startX;
|
const arrowGeometry = calculateArrowGeometry(startPoint, endPoint);
|
||||||
const deltaY = endY - startY;
|
|
||||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
||||||
|
|
||||||
// 控制点偏移量,创建自然的弧线
|
|
||||||
const controlOffset = Math.min(distance * 0.3, 60);
|
|
||||||
const controlX = startX + deltaX * 0.5 + (deltaY > 0 ? -controlOffset : controlOffset);
|
|
||||||
const controlY = startY + deltaY * 0.5 - Math.abs(deltaX) * 0.2;
|
|
||||||
|
|
||||||
return `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`;
|
// 使用统一的路径计算
|
||||||
}, [editPointPosition, connectionEnd]);
|
const path = calculateCurvePath(startPoint, arrowGeometry.center, containerSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
arrowPoints: arrowGeometry.points,
|
||||||
|
arrowTip: arrowGeometry.tip,
|
||||||
|
arrowBase: arrowGeometry.base,
|
||||||
|
arrowCenter: arrowGeometry.center
|
||||||
|
};
|
||||||
|
}, [editPointPosition, connectionEnd, containerSize]);
|
||||||
|
|
||||||
|
// 获取统一的动画配置
|
||||||
|
const animationConfig = useMemo(() =>
|
||||||
|
getConnectionAnimationConfig(true), // EditDescription总是使用动画
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// 获取状态颜色
|
// 获取状态颜色
|
||||||
const getStatusColor = () => {
|
const getStatusColor = () => {
|
||||||
@ -101,7 +113,7 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{editPoint.description && editPoint.status !== EditPointStatus.PENDING && (
|
{editPoint.description && editPoint.status !== EditPointStatus.PENDING && (
|
||||||
<>
|
<>
|
||||||
{/* White dashed connection line to match reference image */}
|
{/* 统一的虚线连接线 - 与EditConnection完全一致 */}
|
||||||
<motion.svg
|
<motion.svg
|
||||||
className="absolute pointer-events-none"
|
className="absolute pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
@ -116,38 +128,39 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
|
{/* 统一的虚线连接线 - 与EditConnection完全一致 */}
|
||||||
<motion.path
|
<motion.path
|
||||||
d={connectionPath}
|
d={connectionGeometry.path}
|
||||||
stroke="rgba(255, 255, 255, 0.9)"
|
stroke={CONNECTION_STYLE.color}
|
||||||
strokeWidth={2}
|
strokeWidth={CONNECTION_STYLE.strokeWidth}
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeDasharray="8,4"
|
strokeDasharray={CONNECTION_STYLE.dashArray}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
initial={{ pathLength: 0, opacity: 0 }}
|
strokeLinejoin="round"
|
||||||
animate={{
|
initial={animationConfig.line?.initial}
|
||||||
pathLength: 1,
|
animate={animationConfig.line?.animate}
|
||||||
opacity: 1
|
exit={animationConfig.line?.initial}
|
||||||
}}
|
|
||||||
exit={{ pathLength: 0, opacity: 0 }}
|
|
||||||
transition={{
|
transition={{
|
||||||
|
...animationConfig.line?.transition,
|
||||||
|
// 稍微延长显示状态的动画时间
|
||||||
pathLength: { duration: 0.8, ease: "easeOut" },
|
pathLength: { duration: 0.8, ease: "easeOut" },
|
||||||
opacity: { duration: 0.5 }
|
opacity: { duration: 0.5 }
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
filter: CONNECTION_STYLE.dropShadow
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Arrow head */}
|
{/* 几何精确的箭头 - 与连接线完美对齐 */}
|
||||||
<motion.polygon
|
<motion.polygon
|
||||||
points={`${connectionEnd.x},${connectionEnd.y} ${connectionEnd.x-8},${connectionEnd.y-4} ${connectionEnd.x-8},${connectionEnd.y+4}`}
|
points={connectionGeometry.arrowPoints.map(p => `${p.x},${p.y}`).join(' ')}
|
||||||
fill="rgba(255, 255, 255, 0.9)"
|
fill={CONNECTION_STYLE.color}
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
initial={animationConfig.arrow?.initial}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={animationConfig.arrow?.animate}
|
||||||
exit={{ scale: 0, opacity: 0 }}
|
exit={animationConfig.arrow?.initial}
|
||||||
transition={{ delay: 0.4, duration: 0.3 }}
|
transition={animationConfig.arrow?.transition}
|
||||||
style={{
|
style={{
|
||||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
filter: CONNECTION_STYLE.dropShadow
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</motion.svg>
|
</motion.svg>
|
||||||
|
|||||||
196
components/pages/work-flow/video-edit/connection-config.ts
Normal file
196
components/pages/work-flow/video-edit/connection-config.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* 视频编辑连接线统一配置
|
||||||
|
* 确保所有连接线组件使用一致的视觉参数和几何计算
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的连接线视觉样式配置
|
||||||
|
*/
|
||||||
|
export const CONNECTION_STYLE = {
|
||||||
|
// 颜色配置
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)', // 统一的白色,确保在深色背景下清晰可见
|
||||||
|
strokeWidth: 2, // 统一的线条粗细
|
||||||
|
dashArray: '8,4', // 统一的虚线样式:8px实线,4px间隔
|
||||||
|
|
||||||
|
// 阴影效果
|
||||||
|
dropShadow: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))',
|
||||||
|
|
||||||
|
// 动画配置
|
||||||
|
animation: {
|
||||||
|
pathDuration: 0.6,
|
||||||
|
pathEasing: 'easeInOut',
|
||||||
|
opacityDuration: 0.3,
|
||||||
|
arrowDelay: 0.4,
|
||||||
|
arrowDuration: 0.3,
|
||||||
|
springConfig: {
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的箭头几何参数
|
||||||
|
*/
|
||||||
|
export const ARROW_GEOMETRY = {
|
||||||
|
size: 8, // 箭头长度
|
||||||
|
halfHeight: 4, // 箭头半高(宽度的一半)
|
||||||
|
centerOffset: 0.6 // 连接线连接到箭头的位置比例(0.6表示稍微向前偏移)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弧线计算参数
|
||||||
|
*/
|
||||||
|
export const CURVE_CONFIG = {
|
||||||
|
curvature: 0.3, // 弧线弯曲程度
|
||||||
|
minControlOffset: 10, // 最小控制点偏移
|
||||||
|
maxControlOffset: 60 // 最大控制点偏移
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算统一的箭头几何形状
|
||||||
|
*/
|
||||||
|
export function calculateArrowGeometry(
|
||||||
|
startPoint: { x: number; y: number },
|
||||||
|
endPoint: { x: number; y: number }
|
||||||
|
) {
|
||||||
|
// 计算连接方向向量
|
||||||
|
const dx = endPoint.x - startPoint.x;
|
||||||
|
const dy = endPoint.y - startPoint.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const normalizedDx = dx / distance;
|
||||||
|
const normalizedDy = dy / distance;
|
||||||
|
|
||||||
|
// 箭头几何计算
|
||||||
|
const arrowTip = { x: endPoint.x, y: endPoint.y };
|
||||||
|
const arrowBase = {
|
||||||
|
x: endPoint.x - normalizedDx * ARROW_GEOMETRY.size,
|
||||||
|
y: endPoint.y - normalizedDy * ARROW_GEOMETRY.size
|
||||||
|
};
|
||||||
|
const arrowCenter = {
|
||||||
|
x: endPoint.x - normalizedDx * (ARROW_GEOMETRY.size * ARROW_GEOMETRY.centerOffset),
|
||||||
|
y: endPoint.y - normalizedDy * (ARROW_GEOMETRY.size * ARROW_GEOMETRY.centerOffset)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算垂直向量用于箭头宽度
|
||||||
|
const perpX = -normalizedDy;
|
||||||
|
const perpY = normalizedDx;
|
||||||
|
|
||||||
|
const arrowPoints = [
|
||||||
|
arrowTip,
|
||||||
|
{
|
||||||
|
x: arrowBase.x + perpX * ARROW_GEOMETRY.halfHeight,
|
||||||
|
y: arrowBase.y + perpY * ARROW_GEOMETRY.halfHeight
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: arrowBase.x - perpX * ARROW_GEOMETRY.halfHeight,
|
||||||
|
y: arrowBase.y - perpY * ARROW_GEOMETRY.halfHeight
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tip: arrowTip,
|
||||||
|
base: arrowBase,
|
||||||
|
center: arrowCenter,
|
||||||
|
points: arrowPoints,
|
||||||
|
direction: { dx: normalizedDx, dy: normalizedDy },
|
||||||
|
perpendicular: { perpX, perpY },
|
||||||
|
distance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算统一的弧线路径
|
||||||
|
*/
|
||||||
|
export function calculateCurvePath(
|
||||||
|
startPoint: { x: number; y: number },
|
||||||
|
endPoint: { x: number; y: number },
|
||||||
|
containerSize: { width: number; height: number }
|
||||||
|
): string {
|
||||||
|
const dx = endPoint.x - startPoint.x;
|
||||||
|
const dy = endPoint.y - startPoint.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// 计算控制点,创建优雅的弧线
|
||||||
|
const midX = (startPoint.x + endPoint.x) / 2;
|
||||||
|
const midY = (startPoint.y + endPoint.y) / 2;
|
||||||
|
|
||||||
|
let controlX = midX;
|
||||||
|
let controlY = midY;
|
||||||
|
|
||||||
|
// 根据方向调整控制点
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
controlY = midY + (dy > 0 ? -1 : 1) * distance * CURVE_CONFIG.curvature;
|
||||||
|
} else {
|
||||||
|
controlX = midX + (dx > 0 ? -1 : 1) * distance * CURVE_CONFIG.curvature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保控制点在容器范围内
|
||||||
|
controlX = Math.max(CURVE_CONFIG.minControlOffset,
|
||||||
|
Math.min(containerSize.width - CURVE_CONFIG.minControlOffset, controlX));
|
||||||
|
controlY = Math.max(CURVE_CONFIG.minControlOffset,
|
||||||
|
Math.min(containerSize.height - CURVE_CONFIG.minControlOffset, controlY));
|
||||||
|
|
||||||
|
// 创建二次贝塞尔曲线路径
|
||||||
|
return `M ${startPoint.x} ${startPoint.y} Q ${controlX} ${controlY} ${endPoint.x} ${endPoint.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动画配置类型定义
|
||||||
|
*/
|
||||||
|
export interface ConnectionAnimationConfig {
|
||||||
|
line: {
|
||||||
|
initial: Record<string, any>;
|
||||||
|
animate: Record<string, any>;
|
||||||
|
transition: Record<string, any>;
|
||||||
|
};
|
||||||
|
arrow: {
|
||||||
|
initial: Record<string, any>;
|
||||||
|
animate: Record<string, any>;
|
||||||
|
transition: Record<string, any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取统一的动画配置
|
||||||
|
*/
|
||||||
|
export function getConnectionAnimationConfig(animated: boolean = true): ConnectionAnimationConfig {
|
||||||
|
if (!animated) {
|
||||||
|
return {
|
||||||
|
line: {
|
||||||
|
initial: {},
|
||||||
|
animate: {},
|
||||||
|
transition: {}
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
initial: {},
|
||||||
|
animate: {},
|
||||||
|
transition: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
line: {
|
||||||
|
initial: { pathLength: 0, opacity: 0 },
|
||||||
|
animate: { pathLength: 1, opacity: 1 },
|
||||||
|
transition: {
|
||||||
|
pathLength: {
|
||||||
|
duration: CONNECTION_STYLE.animation.pathDuration,
|
||||||
|
ease: CONNECTION_STYLE.animation.pathEasing as any
|
||||||
|
},
|
||||||
|
opacity: { duration: CONNECTION_STYLE.animation.opacityDuration }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
initial: { scale: 0, opacity: 0 },
|
||||||
|
animate: { scale: 1, opacity: 1 },
|
||||||
|
transition: {
|
||||||
|
delay: CONNECTION_STYLE.animation.arrowDelay,
|
||||||
|
duration: CONNECTION_STYLE.animation.arrowDuration,
|
||||||
|
type: "spring" as const,
|
||||||
|
...CONNECTION_STYLE.animation.springConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, EffectCoverflow } from 'swiper/modules';
|
import { Autoplay, EffectCoverflow, EffectCards } from 'swiper/modules';
|
||||||
import type { Swiper as SwiperType } from 'swiper/types';
|
import type { Swiper as SwiperType } from 'swiper/types';
|
||||||
|
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||||
|
|
||||||
import 'swiper/css';
|
import 'swiper/css';
|
||||||
import 'swiper/css/effect-coverflow';
|
import 'swiper/css/effect-coverflow';
|
||||||
|
import 'swiper/css/effect-cards';
|
||||||
|
|
||||||
/** 默认视频列表(来自 home-page2.tsx 中的数组) */
|
/** 默认视频列表(来自 home-page2.tsx 中的数组) */
|
||||||
const DEFAULT_VIDEOS: string[] = [
|
const DEFAULT_VIDEOS: string[] = [
|
||||||
@ -40,21 +42,22 @@ const VideoCoverflow: React.FC<VideoCoverflowProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const swiperRef = React.useRef<SwiperType | null>(null);
|
const swiperRef = React.useRef<SwiperType | null>(null);
|
||||||
const videoRefs = React.useRef<Record<number, HTMLVideoElement | null>>({});
|
const videoRefs = React.useRef<Record<number, HTMLVideoElement | null>>({});
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean>(false);
|
const { isMobile } = useDeviceType();
|
||||||
const [activeIndex, setActiveIndex] = React.useState<number>(0);
|
const [activeIndex, setActiveIndex] = React.useState<number>(0);
|
||||||
|
|
||||||
const playActive = React.useCallback((activeIndex: number) => {
|
const playActive = React.useCallback((activeIndex: number) => {
|
||||||
Object.entries(videoRefs.current).forEach(([key, el]) => {
|
Object.entries(videoRefs.current).forEach(([key, el]) => {
|
||||||
if (!el) return;
|
const video = el as HTMLVideoElement | null;
|
||||||
|
if (!video) return;
|
||||||
const index = Number(key);
|
const index = Number(key);
|
||||||
if (index === activeIndex) {
|
if (index === activeIndex) {
|
||||||
// 尝试播放当前居中视频
|
// 尝试播放当前居中视频
|
||||||
el.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
// 暂停其他视频,重置到起点以减少解码负担
|
// 暂停其他视频,重置到起点以减少解码负担
|
||||||
el.pause();
|
video.pause();
|
||||||
try {
|
try {
|
||||||
el.currentTime = 0;
|
video.currentTime = 0;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -115,28 +118,31 @@ const VideoCoverflow: React.FC<VideoCoverflowProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
<div data-alt="video-coverflow" className="w-screen sm:w-full mx-auto overflow-hidden">
|
<div data-alt="video-coverflow" className="w-screen sm:w-full mx-auto overflow-hidden">
|
||||||
<Swiper
|
<Swiper
|
||||||
modules={[Autoplay, EffectCoverflow]}
|
modules={isMobile ? [Autoplay, EffectCards] : [Autoplay, EffectCoverflow]}
|
||||||
effect="coverflow"
|
effect={isMobile ? 'cards' : 'coverflow'}
|
||||||
|
key={isMobile ? 'cards' : 'coverflow'}
|
||||||
centeredSlides
|
centeredSlides
|
||||||
slidesPerView={isMobile ? 1 : 2}
|
slidesPerView={isMobile ? 1 : 2}
|
||||||
loop
|
loop
|
||||||
autoplay={{ delay: autoplayDelay, disableOnInteraction: false }}
|
autoplay={{ delay: autoplayDelay, disableOnInteraction: false }}
|
||||||
speed={1000}
|
speed={1000}
|
||||||
coverflowEffect={{
|
{...(!isMobile ? {
|
||||||
rotate: -56,
|
coverflowEffect: {
|
||||||
stretch: 10,
|
rotate: -56,
|
||||||
depth: 80,
|
stretch: 10,
|
||||||
scale: 0.6,
|
depth: 80,
|
||||||
modifier: 1,
|
scale: 0.6,
|
||||||
slideShadows: true,
|
modifier: 1,
|
||||||
}}
|
slideShadows: true,
|
||||||
|
},
|
||||||
|
} : {})}
|
||||||
onAfterInit={handleAfterInit}
|
onAfterInit={handleAfterInit}
|
||||||
onSlideChange={handleSlideChange}
|
onSlideChange={handleSlideChange}
|
||||||
className="w-full py-8"
|
className="w-full py-8"
|
||||||
>
|
>
|
||||||
{videos.map((src, index) => (
|
{videos.map((src, index) => (
|
||||||
<SwiperSlide key={src} className="select-none">
|
<SwiperSlide key={src} className="select-none">
|
||||||
<div data-alt="video-card" className={`${isMobile ? (activeIndex === index ? 'w-screen' : 'w-[80vw]') : 'w-[48vw]'} mx-auto aspect-video overflow-hidden rounded-xl shadow-lg`}>
|
<div data-alt="video-card" className={`${isMobile ? 'w-screen' : 'w-[48vw]'} mx-auto aspect-video overflow-hidden rounded-xl shadow-lg`}>
|
||||||
<video
|
<video
|
||||||
data-alt="video"
|
data-alt="video"
|
||||||
ref={(el) => { videoRefs.current[index] = el; }}
|
ref={(el) => { videoRefs.current[index] = el; }}
|
||||||
|
|||||||
@ -2,7 +2,32 @@
|
|||||||
* 服务端配置工具函数
|
* 服务端配置工具函数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { post } from '@/api/request';
|
// 注意:这里不使用 @/api/request 中的 post 函数,因为它会将请求发送到远程服务器
|
||||||
|
// 我们需要直接调用本地的 Next.js API 路由
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地API请求函数 - 直接调用Next.js API路由
|
||||||
|
*/
|
||||||
|
const localPost = async <T>(url: string, data: any): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Local API request failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSO配置接口
|
* SSO配置接口
|
||||||
@ -14,6 +39,13 @@ export interface SSOConfig {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频修改配置接口
|
||||||
|
*/
|
||||||
|
export interface VideoModificationConfig {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取SSO配置
|
* 获取SSO配置
|
||||||
* @returns Promise<SSOConfig | null>
|
* @returns Promise<SSOConfig | null>
|
||||||
@ -21,7 +53,7 @@ export interface SSOConfig {
|
|||||||
export const getSSOConfig = async (): Promise<SSOConfig | null> => {
|
export const getSSOConfig = async (): Promise<SSOConfig | null> => {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 开始获取SSO配置...');
|
console.log('🔍 开始获取SSO配置...');
|
||||||
const res = await post<any>(`/api/server-setting/find_by_code`, { code: 'sso_config' });
|
const res = await localPost<any>(`/api/server-setting/find_by_code`, { code: 'sso_config' });
|
||||||
|
|
||||||
console.log('📋 SSO API响应:', res);
|
console.log('📋 SSO API响应:', res);
|
||||||
|
|
||||||
@ -64,21 +96,21 @@ export const isGoogleLoginEnabled = async (): Promise<boolean> => {
|
|||||||
console.log('🔍 检查Google登录是否启用...');
|
console.log('🔍 检查Google登录是否启用...');
|
||||||
const config = await getSSOConfig();
|
const config = await getSSOConfig();
|
||||||
console.log('📋 获得的配置:', config);
|
console.log('📋 获得的配置:', config);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
console.log('❌ 没有获得配置,返回false');
|
console.log('❌ 没有获得配置,返回false');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEnabled = config?.show === true;
|
const isEnabled = config?.show === true;
|
||||||
|
|
||||||
console.log('🔍 配置检查:', {
|
console.log('🔍 配置检查:', {
|
||||||
show: config?.show,
|
show: config?.show,
|
||||||
provider: config?.provider,
|
provider: config?.provider,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
finalResult: isEnabled
|
finalResult: isEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
// 简化逻辑:只检查show字段,因为sso_config专门用于Google登录
|
// 简化逻辑:只检查show字段,因为sso_config专门用于Google登录
|
||||||
return isEnabled;
|
return isEnabled;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -86,3 +118,75 @@ export const isGoogleLoginEnabled = async (): Promise<boolean> => {
|
|||||||
return false; // 出错时默认不显示
|
return false; // 出错时默认不显示
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频修改配置
|
||||||
|
* @returns Promise<VideoModificationConfig | null>
|
||||||
|
*/
|
||||||
|
export const getVideoModificationConfig = async (): Promise<VideoModificationConfig | null> => {
|
||||||
|
try {
|
||||||
|
console.log('🔍 开始获取视频修改配置...');
|
||||||
|
const res = await localPost<any>(`/api/server-setting/find_by_code`, { code: 'video_modification' });
|
||||||
|
|
||||||
|
console.log('📋 视频修改配置API响应:', res);
|
||||||
|
|
||||||
|
if (!res || res.code !== 0 || !res.successful || !res.data) {
|
||||||
|
console.warn('❌ Failed to fetch video modification config:', res);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新的数据格式:data直接包含id, code, value等字段
|
||||||
|
const { value } = res.data;
|
||||||
|
console.log('📝 视频修改配置原始value:', value);
|
||||||
|
console.log('📝 value类型:', typeof value, 'value长度:', value?.length);
|
||||||
|
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
console.warn('❌ Invalid video modification config format:', value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config: VideoModificationConfig = JSON.parse(value);
|
||||||
|
console.log('✅ 视频修改配置解析成功:', config);
|
||||||
|
return config;
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('❌ Failed to parse video modification config:', parseError);
|
||||||
|
console.error('❌ 原始value:', JSON.stringify(value));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fetching video modification config:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用视频修改功能
|
||||||
|
* @returns Promise<boolean>
|
||||||
|
*/
|
||||||
|
export const isVideoModificationEnabled = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
console.log('🔍 检查视频修改功能是否启用...');
|
||||||
|
const config = await getVideoModificationConfig();
|
||||||
|
console.log('📋 获得的视频修改配置:', config);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
console.log('❌ 没有获得视频修改配置,返回false');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEnabled = config?.show === true;
|
||||||
|
|
||||||
|
console.log('🔍 视频修改配置检查:', {
|
||||||
|
show: config?.show,
|
||||||
|
isEnabled,
|
||||||
|
finalResult: isEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
// 简化逻辑:只检查show字段
|
||||||
|
return isEnabled;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error checking video modification status:', error);
|
||||||
|
return false; // 出错时默认不显示
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -93,6 +93,7 @@
|
|||||||
"react-joyride": "^2.9.3",
|
"react-joyride": "^2.9.3",
|
||||||
"react-lazyload": "^3.2.1",
|
"react-lazyload": "^3.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
@ -17064,6 +17065,15 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-masonry-css": {
|
||||||
|
"version": "1.0.16",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz",
|
||||||
|
"integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
|||||||
@ -98,6 +98,7 @@
|
|||||||
"react-joyride": "^2.9.3",
|
"react-joyride": "^2.9.3",
|
||||||
"react-lazyload": "^3.2.1",
|
"react-lazyload": "^3.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
|
|||||||
@ -73,12 +73,18 @@ module.exports = {
|
|||||||
filter: "url(#toggle-glass) blur(2px)",
|
filter: "url(#toggle-glass) blur(2px)",
|
||||||
transform: "scale(1)",
|
transform: "scale(1)",
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
wiggle: {
|
||||||
|
'0%, 60%': { transform: 'rotate(0deg)' },
|
||||||
|
'70%, 90%': { transform: 'rotate(-6deg)' },
|
||||||
|
'80%': { transform: 'rotate(6deg)' },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
"liquid-toggle": "liquid-toggle 2s ease-in-out infinite",
|
"liquid-toggle": "liquid-toggle 2s ease-in-out infinite",
|
||||||
|
"wiggle": "wiggle 1s ease-in-out infinite",
|
||||||
},
|
},
|
||||||
transitionDelay: {
|
transitionDelay: {
|
||||||
'100': '100ms',
|
'100': '100ms',
|
||||||
|
|||||||
@ -110,10 +110,10 @@ export const downloadAllVideos = async (urls: string[]) => {
|
|||||||
* 发现 链接 包含 ‘aliyuncs.com’ 是阿里云地址
|
* 发现 链接 包含 ‘aliyuncs.com’ 是阿里云地址
|
||||||
* @param url 视频URL
|
* @param url 视频URL
|
||||||
*/
|
*/
|
||||||
export const getFirstFrame = (url: string) => {
|
export const getFirstFrame = (url: string, width?: number) => {
|
||||||
if (url.includes('aliyuncs.com')) {
|
if (url.includes('aliyuncs.com')) {
|
||||||
return url + '?x-oss-process=video/snapshot,t_1000,f_jpg';
|
return url + '?x-oss-process=video/snapshot,t_1000,f_jpg' + `${width ? ',w_'+width : ''}`;
|
||||||
} else {
|
} else {
|
||||||
return url + '?vframe/jpg/offset/1';
|
return url + '?vframe/jpg/offset/1' + `${width ? '/w/'+width : ''}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user