Merge branch 'dev' into prod

This commit is contained in:
moux1024 2025-09-24 21:30:22 +08:00
commit ef6c4b3e97
27 changed files with 1109 additions and 302 deletions

View File

@ -1,5 +1,5 @@
NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com

View File

@ -69,13 +69,14 @@ interface ListMovieProjectsParams {
per_page: number;
}
interface MovieProject {
export interface MovieProject {
project_id: string;
name: string;
status: string;
step: string;
final_video_url: string;
final_simple_video_url: string;
video_urls: string;
last_message: string;
updated_at: string;
created_at: string;

View File

@ -1,7 +1,8 @@
"use client";
import { post } from "@/api/request";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
export default function Activate() {
@ -21,10 +22,12 @@ export default function Activate() {
* @param {string} t - Verification token
*/
function ConfirmEmail({ t }: { t: string }) {
const router = useRouter();
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading"
);
const [message, setMessage] = useState("");
const [countdown, setCountdown] = useState(3);
useEffect(() => {
if (!t) {
@ -35,18 +38,36 @@ function ConfirmEmail({ t }: { t: string }) {
post(`/auth/activate`, {
t: t,
}).then((res:any) => {
console.log('res', res)
setStatus("success");
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) => {
console.log('err', err)
setStatus("error");
setMessage("Verification failed. Please try again.");
});
}, [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 = () => {
switch (status) {
case "loading":
@ -77,6 +98,18 @@ function ConfirmEmail({ t }: { t: string }) {
Verification Successful
</h2>
<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>
);

View File

@ -60,6 +60,23 @@ export async function POST(request: NextRequest) {
};
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:
// 默认返回空配置
responseData = {

View File

@ -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>
</div>
</header>
{/* 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 1: My Invitation Link */}
<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-box" className="sm:col-span-2">
@ -277,6 +248,74 @@ export default function SharePage(): JSX.Element {
</div>
</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 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">

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

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import {
ChevronDown,
ChevronUp,
@ -103,6 +103,33 @@ const debounce = (func: Function, wait: number) => {
*/
export function ChatInputBox({ noData }: { noData: boolean }) {
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);
@ -148,7 +175,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
language: "english",
videoDuration: "unlimited",
expansion_mode: true,
aspect_ratio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
aspect_ratio: isMobile ? "VIDEO_ASPECT_RATIO_PORTRAIT" : "VIDEO_ASPECT_RATIO_LANDSCAPE",
});
// 从 localStorage 初始化配置
@ -163,19 +190,38 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
language: parsed.language || "english",
videoDuration: parsed.videoDuration || "unlimited",
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) {
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]) => {
setConfigOptions((prev: ConfigOptions) => ({
...prev,
[key]: value,
}));
// 如果用户手动修改了宽高比,标记为已修改
if (key === 'aspect_ratio') {
setHasUserChangedAspectRatio(true);
}
if (key === 'videoDuration') {
// 当选择 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 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) ? (
// 骨架屏:若正在加载且没有数据
Array.from({ length: 6 }).map((_, idx) => (
@ -525,7 +578,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
<button
key={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={() => {
// id 映射:优先使用模板的 id若需要兼容 template_id则传两者之一
setInitialTemplateId(tpl.id || (tpl as any).template_id);

View File

@ -80,7 +80,7 @@ export const H5PhotoStoryDrawer = ({
const { loadingText } = useLoadScriptText(isLoading);
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 taskProgressRef = useRef(taskProgress);
const [cursorPosition, setCursorPosition] = useState(0);

View File

@ -79,7 +79,7 @@ export const H5TemplateDrawer = ({
const [isDescExpanded, setIsDescExpanded] = useState(false);
// 自由输入框布局
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 才是滚动容器)
const topSectionRef = useRef<HTMLDivElement | null>(null);

View File

@ -19,6 +19,8 @@ interface SmartChatBoxProps {
onClearPreview?: () => void;
setIsFocusChatInput?: (v: boolean) => void;
aiEditingResult?: any;
/** 新消息回调:用于外层处理未展开时的气泡提示 */
onNewMessage?: (snippet: string) => void;
}
interface MessageGroup {
@ -47,7 +49,8 @@ export default function SmartChatBox({
previewVideoId,
onClearPreview,
setIsFocusChatInput,
aiEditingResult
aiEditingResult,
onNewMessage
}: SmartChatBoxProps) {
// 消息列表引用
const listRef = useRef<HTMLDivElement>(null);
@ -103,6 +106,23 @@ export default function SmartChatBox({
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(() => {
// if (aiEditingResult && isSmartChatBoxOpen) {
@ -179,7 +199,7 @@ export default function SmartChatBox({
)}
{/* Messages grouped by date */}
<div className="space-y-3">
<div className="space-y-3 pb-28">
{groupedMessages.map((group) => (
<React.Fragment key={group.date}>
<DateDivider timestamp={group.date} />

View File

@ -61,10 +61,10 @@ export default function SigninPage() {
if (isInitialLoading) {
return (
<div className="mx-auto max-w-md space-y-6">
<Card className="bg-transparent border-0 shadow-none">
<CardContent className="flex items-center justify-center py-12">
<div className="flex items-center gap-2 text-muted-foreground">
<div data-alt="signin-container" className="mx-auto w-full max-w-sm space-y-6">
<Card data-alt="loading-card" className="bg-transparent border-0 shadow-none">
<CardContent data-alt="loading-content" className="flex items-center justify-center py-12">
<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" />
Loading...
</div>
@ -75,47 +75,53 @@ export default function SigninPage() {
}
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 */}
<Card className="bg-transparent border-0 shadow-none">
<CardHeader 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">
<Card data-alt="signin-card" className="bg-transparent border-0 shadow-none">
<CardHeader data-alt="signin-header" className="text-center pb-4 pt-0">
<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
</h1>
<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>
<div className="relative">
<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" data-alt="signin-help-wrapper">
<button
data-alt="signin-help-button"
onMouseEnter={() => setShowTip(true)}
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" />
</button>
{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">
<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"> Credits are valid for 7 days</p>
<p className="text-muted-foreground"> Expired credits will be automatically cleared</p>
</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>
</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="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">
<Coins className="w-6 h-6 text-primary" />
<span className="text-sm text-muted-foreground">Earned Credits</span>
</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}
</div>
</div>
@ -124,9 +130,10 @@ export default function SigninPage() {
{/* Sign-in button */}
<Button
data-alt="signin-button"
onClick={handleSignin}
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"
>
{isLoading ? (

View File

@ -13,7 +13,7 @@ import {
Sun,
Moon,
User,
Sparkles,
Gift,
LogOut,
PanelsLeftBottom,
Bell,
@ -261,7 +261,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
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">
{pathname === "/" && (
<button
@ -432,9 +432,17 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
{/* AI 积分 */}
<div className="flex flex-col items-center mb-3">
<div className="flex items-center justify-center space-x-3 mb-2">
<div className="p-2 rounded-full bg-white/10 backdrop-blur-sm">
<Sparkles className="h-5 w-5 text-white" />
</div>
<button
type="button"
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">
{isLoadingSubscription
? "Loading..."
@ -568,9 +576,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
data-alt="signin-modal"
>
<DialogTitle></DialogTitle>
<div className="p-4">
<SigninBox />
</div>
</DialogContent>
</Dialog>
</div>

View File

@ -5,13 +5,14 @@ import { Loader2, Download } from 'lucide-react';
import { useRouter } from 'next/navigation';
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 cover_image1 from '@/public/assets/cover_image3.jpg';
import { motion } from 'framer-motion';
import { Tooltip, Button } from 'antd';
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() {
const router = useRouter();
const [episodeList, setEpisodeList] = useState<any[]>([]);
const [episodeList, setEpisodeList] = useState<MovieProject[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [perPage] = useState(12);
const [perPage] = useState(28);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isPreloading, setIsPreloading] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [userId, setUserId] = useState<number>(0);
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 来跟踪当前正在加载的页码
const loadingPageRef = useRef<number | null>(null);
// 在客户端挂载后读取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(() => {
if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
if (scrollHeight - scrollTop - clientHeight < 100) {
// 直接使用 currentPage不再使用 setCurrentPage 的回调
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// 在滚动到 30% 时预加载下一页
// if (scrollPercentage > 0.30 && !isPreloading && currentPage < totalPages) {
// preloadNextPage(userId, currentPage + 1);
// }
// 在滚动到 70% 时加载下一页
if (scrollPercentage > 0.7) {
const nextPage = currentPage + 1;
if (nextPage <= totalPages) {
getEpisodeList(userId, nextPage, true);
}
}
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage]);
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage, isPreloading]);
useEffect(() => {
if (typeof window !== 'undefined') {
@ -67,6 +108,16 @@ export default function CreateToVideo2() {
}
}, [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) => {
// 检查是否正在加载该页
@ -83,13 +134,15 @@ export default function CreateToVideo2() {
}
try {
const params = {
user_id: String(userId),
page,
per_page: perPage
};
let episodeListResponse;
const episodeListResponse = await getScriptEpisodeListNew(params);
// 如果有预加载的数据且页码匹配,直接使用
if (preloadedDataRef.current && preloadedDataRef.current.page === page) {
episodeListResponse = preloadedDataRef.current.data;
preloadedDataRef.current = null;
} else {
episodeListResponse = await fetchEpisodeData(userId, page);
}
if (episodeListResponse.code === 0) {
const { movie_projects, total_pages } = episodeListResponse.data;
@ -109,6 +162,11 @@ export default function CreateToVideo2() {
setTotalPages(total_pages);
setHasMore(page < total_pages);
setCurrentPage(page);
// 预加载下一页数据
// if (page < total_pages && !isPreloading) {
// preloadNextPage(userId, page + 1);
// }
}
} 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 (
<LazyLoad key={project.project_id} once>
<div
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)}
onMouseLeave={() => handleMouseLeave(project.project_id)}
data-alt="project-card"
>
{/* 视频/图片区域 */}
<div className="relative w-full pb-[56.25%]" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
{/* 视频/图片区域(使用 aspect_ratio 预设高度) */}
<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) ? (
<video
ref={(el) => setVideoRef(project.project_id, el)}
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"
src={project.final_video_url || project.final_simple_video_url || project.video_urls || ''}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
muted
loop
playsInline
preload="none"
preload="auto"
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
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={{
backgroundImage: `url(${cover_image1.src})`,
}}
data-alt="cover-image"
/>
)}
@ -247,7 +339,7 @@ export default function CreateToVideo2() {
</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 gap-2">
<h2 className="text-sm font-medium text-white line-clamp-1">
@ -265,7 +357,7 @@ export default function CreateToVideo2() {
</div>
</div>
</div>
</LazyLoad>
);
};
@ -282,9 +374,20 @@ export default function CreateToVideo2() {
{episodeList.length > 0 && (
/* 优化的剧集网格 */
<div className="pb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{(() => {
const masonryBreakpoints = { default: 5, 1024: 2, 640: 1 };
return (
<Masonry
ref={masonryRef}
breakpointCols={masonryBreakpoints}
className="flex -ml-2"
columnClassName="pl-2 space-y-2"
data-alt="masonry-grid"
>
{episodeList.map(renderProjectCard)}
</div>
</Masonry>
);
})()}
{/* 加载更多指示器 */}
{isLoadingMore && (

View File

@ -276,7 +276,7 @@ export function HomePage2() {
</LazyLoad>
{/* 动态锚点:来源于服务端 homeTab 配置title 作为锚点与标题 */}
{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} />
</div>
))}

View File

@ -10,7 +10,7 @@ import { MediaViewer } from "./work-flow/media-viewer";
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
import { useWorkflowData } from "./work-flow/use-workflow-data";
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 { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
import { useSearchParams } from "next/navigation";
@ -48,6 +48,8 @@ const WorkFlow = React.memo(function WorkFlow() {
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
const [activeEditTab, setActiveEditTab] = React.useState('1');
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 [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
@ -617,15 +619,30 @@ Please process this video editing request.`;
{/* 智能对话按钮 */}
<div
className="fixed right-[1rem] bottom-[10rem] z-[49]"
className={`fixed right-[1rem] z-[49] ${isMobile ? 'bottom-[2rem]' : 'bottom-[10rem]'}`}
>
{isMobile ? (
<div className="relative">
{(!isSmartChatBoxOpen && chatTip) && (
<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">
{chatTip}
</div>
)}
{/* 红点徽标 */}
{(!isSmartChatBoxOpen && hasUnread) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border border-white" />
)}
<GlassIconButton
icon={Bot}
icon={MessageCircle}
size='md'
onClick={() => setIsSmartChatBoxOpen(true)}
className="backdrop-blur-lg"
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">
<GlassIconButton
@ -641,6 +658,7 @@ Please process this video editing request.`;
{/* 智能对话弹窗 */}
<Drawer
width={isMobile ? '100vw' : '25%'}
height={isMobile ? 'auto' : ''}
placement={isMobile ? 'bottom' : 'right'}
closable={false}
maskClosable={false}
@ -648,9 +666,9 @@ Please process this video editing request.`;
getContainer={false}
autoFocus={false}
mask={false}
zIndex={49}
zIndex={60}
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={{
backgroundColor: 'transparent',
...(isMobile
@ -676,6 +694,14 @@ Please process this video editing request.`;
previewVideoUrl={previewVideoUrl}
previewVideoId={previewVideoId}
setIsFocusChatInput={setIsFocusChatInput}
onNewMessage={(snippet) => {
if (!isSmartChatBoxOpen && snippet) {
setChatTip(snippet);
setHasUnread(true);
// 5秒后自动消失
setTimeout(() => setChatTip(null), 5000);
}
}}
onClearPreview={() => {
setPreviewVideoUrl(null);
setPreviewVideoId(null);

View File

@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Carousel } from 'antd';
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 { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
import ScriptLoading from './script-loading';
@ -395,15 +395,15 @@ export function H5MediaViewer({
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
{/* 全局固定操作区(右角)视频暂停时展示 */}
{/* 全局固定操作区(右角)视频暂停时展示 */}
{(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' && (
<>
<GlassIconButton
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"
icon={MessageCircleMore}
className="w-8 h-8 bg-custom-purple backdrop-blur-xl rounded-full flex items-center justify-center transition-all"
icon={FeatherIcon}
size="sm"
aria-label="edit-with-chat"
onClick={() => {
@ -416,7 +416,7 @@ export function H5MediaViewer({
/>
<GlassIconButton
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}
size="sm"
aria-label="download-all"
@ -430,7 +430,7 @@ export function H5MediaViewer({
return status === 1 ? (
<GlassIconButton
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}
size="sm"
aria-label="download-current"

View File

@ -2,7 +2,7 @@
import React, { useEffect, useMemo, useState } from 'react'
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'
interface H5TaskInfoProps {
@ -94,6 +94,34 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
return null
}, [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 (
<div
data-alt="h5-header"
@ -101,9 +129,10 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
>
<div
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
data-alt="title"
className="text-white text-lg font-bold"
@ -116,7 +145,58 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
{subtitle}
</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>

View File

@ -14,6 +14,7 @@ import { Button, Tooltip } from 'antd';
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
import { EditPoint as EditPointType } from './video-edit/types';
import { isVideoModificationEnabled } from '@/lib/server-config';
interface MediaViewerProps {
taskObject: TaskObject;
@ -78,6 +79,8 @@ export const MediaViewer = React.memo(function MediaViewer({
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false);
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
// 控制钢笔图标显示的状态 - 参考谷歌登录按钮的实现
const [showVideoModification, setShowVideoModification] = useState(false);
useEffect(() => {
if (isSmartChatBoxOpen) {
@ -89,6 +92,33 @@ export const MediaViewer = React.memo(function MediaViewer({
}
}, [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("❌ MediaViewerFailed to check video modification status:", error);
setShowVideoModification(false); // 出错时默认不显示
}
};
checkVideoModificationStatus();
}, []); // 只在组件挂载时执行一次
// 调试:监控钢笔图标显示状态
useEffect(() => {
console.log('🔧 MediaViewer状态更新:', {
enableVideoEdit,
showVideoModification,
shouldShowPenIcon: enableVideoEdit && showVideoModification
});
}, [enableVideoEdit, showVideoModification]);
// 音量控制函数
const toggleMute = () => {
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={{
right: toosBtnRight
}}>
{/* 视频编辑模式切换按钮 - 临时注释 */}
{/* {enableVideoEdit && (
{/* 视频编辑模式切换按钮 - 通过服务器配置控制显示 */}
{enableVideoEdit && showVideoModification && (
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
<GlassIconButton
icon={PenTool}
size='sm'
onClick={() => setIsVideoEditMode(!isVideoEditMode)}
onClick={() => {
console.log('🖊️ 钢笔图标被点击,切换编辑模式:', !isVideoEditMode);
setIsVideoEditMode(!isVideoEditMode);
}}
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
/>
</Tooltip>
)} */}
)}
{/* 添加到chat去编辑 按钮 */}
<Tooltip placement="top" title="Edit video with chat">
<GlassIconButton icon={MessageCircleMore} size='sm' onClick={() => {

View File

@ -6,6 +6,13 @@
import React, { useMemo } from 'react';
import { motion } from 'framer-motion';
import { ConnectionPathParams, InputBoxPosition } from './types';
import {
CONNECTION_STYLE,
ARROW_GEOMETRY,
calculateArrowGeometry,
calculateCurvePath as calculateUnifiedCurvePath,
getConnectionAnimationConfig
} from './connection-config';
interface EditConnectionProps {
/** 起始点坐标(编辑点位置) */
@ -95,7 +102,8 @@ export function calculateInputPosition(
direction = 'right';
inputX = pointX + connectionLength;
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
connectionEndX = inputX;
// 箭头指向输入框左边缘的中心
connectionEndX = inputX - 8; // 向内偏移8px指向输入框内部
connectionEndY = inputY + inputHeight / 2;
}
// 其次选择左侧
@ -103,7 +111,8 @@ export function calculateInputPosition(
direction = 'left';
inputX = pointX - connectionLength - inputWidth;
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
connectionEndX = inputX + inputWidth;
// 箭头指向输入框右边缘的中心
connectionEndX = inputX + inputWidth + 8; // 向内偏移8px指向输入框内部
connectionEndY = inputY + inputHeight / 2;
}
// 然后选择下方
@ -111,23 +120,26 @@ export function calculateInputPosition(
direction = 'bottom';
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
inputY = pointY + connectionLength;
// 箭头指向输入框上边缘的中心
connectionEndX = inputX + inputWidth / 2;
connectionEndY = inputY;
connectionEndY = inputY - 8; // 向内偏移8px指向输入框内部
}
// 最后选择上方
else if (spaceTop >= inputHeight + connectionLength + margin) {
direction = 'top';
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
inputY = pointY - connectionLength - inputHeight;
// 箭头指向输入框下边缘的中心
connectionEndX = inputX + inputWidth / 2;
connectionEndY = inputY + inputHeight;
connectionEndY = inputY + inputHeight + 8; // 向内偏移8px指向输入框内部
}
// 如果空间不足,强制放在右侧并调整位置
else {
direction = 'right';
inputX = Math.min(containerWidth - inputWidth - margin, pointX + 40);
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
connectionEndX = inputX;
// 箭头指向输入框左边缘的中心
connectionEndX = inputX - 8; // 向内偏移8px指向输入框内部
connectionEndY = inputY + inputHeight / 2;
}
@ -150,80 +162,30 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
curvature = 0.3,
animated = true
}) => {
// 使用统一的样式配置
const {
color = 'rgba(255, 255, 255, 0.9)', // White color to match the reference image
strokeWidth = 2,
dashArray = '8,4' // Dashed line to match the reference image
color = CONNECTION_STYLE.color,
strokeWidth = CONNECTION_STYLE.strokeWidth,
dashArray = CONNECTION_STYLE.dashArray
} = style;
// 计算箭头几何参数
const arrowSize = 8;
const arrowHalfHeight = 4;
// 使用统一的箭头几何计算
const arrowGeometry = useMemo(() =>
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(() =>
calculateCurvePath({
start: startPoint,
end: arrowGeometry.base, // 连接到箭头底部中心而不是尖端
containerSize,
curvature
}), [startPoint, arrowGeometry.base, containerSize, curvature]);
calculateUnifiedCurvePath(startPoint, arrowGeometry.center, containerSize),
[startPoint, arrowGeometry.center, containerSize]
);
// 计算路径长度用于动画
const pathLength = useMemo(() => {
const dx = arrowGeometry.base.x - startPoint.x;
const dy = arrowGeometry.base.y - startPoint.y;
return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20%
}, [startPoint, arrowGeometry.base]);
// 获取统一的动画配置
const animationConfig = useMemo(() =>
getConnectionAnimationConfig(animated),
[animated]
);
return (
<svg
@ -232,7 +194,7 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
height={containerSize.height}
style={{ zIndex: 10 }}
>
{/* Curved dashed line - properly aligned to arrow base center */}
{/* 统一的虚线连接线 - 精确连接到箭头中心 */}
<motion.path
d={path}
fill="none"
@ -241,44 +203,23 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
strokeDasharray={dashArray}
strokeLinecap="round"
strokeLinejoin="round"
initial={animated ? {
pathLength: 0,
opacity: 0
} : {}}
animate={animated ? {
pathLength: 1,
opacity: 1
} : {}}
transition={animated ? {
pathLength: { duration: 0.6, ease: "easeInOut" },
opacity: { duration: 0.3 }
} : {}}
initial={animationConfig.line.initial}
animate={animationConfig.line.animate}
transition={animationConfig.line.transition}
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
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
fill={color}
initial={animated ? {
scale: 0,
opacity: 0
} : {}}
animate={animated ? {
scale: 1,
opacity: 1
} : {}}
transition={animated ? {
delay: 0.4,
duration: 0.3,
type: "spring",
stiffness: 300,
damping: 25
} : {}}
initial={animationConfig.arrow.initial}
animate={animationConfig.arrow.animate}
transition={animationConfig.arrow.transition}
style={{
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
filter: CONNECTION_STYLE.dropShadow
}}
/>

View File

@ -6,6 +6,12 @@
import React, { useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { EditPoint as EditPointType, EditPointStatus } from './types';
import {
CONNECTION_STYLE,
calculateArrowGeometry,
calculateCurvePath,
getConnectionAnimationConfig
} from './connection-config';
interface EditDescriptionProps {
/** 编辑点数据 */
@ -42,25 +48,31 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
y: (editPoint.position.y / 100) * containerSize.height
}), [editPoint.position, containerSize]);
// 计算连接线路径
const connectionPath = useMemo(() => {
const startX = editPointPosition.x;
const startY = editPointPosition.y;
const endX = connectionEnd.x;
const endY = connectionEnd.y;
// 使用统一的连接线几何计算
const connectionGeometry = useMemo(() => {
const startPoint = { x: editPointPosition.x, y: editPointPosition.y };
const endPoint = { x: connectionEnd.x, y: connectionEnd.y };
// 计算控制点,创建优雅的弧线
const deltaX = endX - startX;
const deltaY = endY - startY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// 使用统一的箭头几何计算
const arrowGeometry = calculateArrowGeometry(startPoint, endPoint);
// 控制点偏移量,创建自然的弧线
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;
// 使用统一的路径计算
const path = calculateCurvePath(startPoint, arrowGeometry.center, containerSize);
return `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`;
}, [editPointPosition, connectionEnd]);
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 = () => {
@ -101,7 +113,7 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
<AnimatePresence>
{editPoint.description && editPoint.status !== EditPointStatus.PENDING && (
<>
{/* White dashed connection line to match reference image */}
{/* 统一的虚线连接线 - 与EditConnection完全一致 */}
<motion.svg
className="absolute pointer-events-none"
style={{
@ -116,38 +128,39 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{/* 统一的虚线连接线 - 与EditConnection完全一致 */}
<motion.path
d={connectionPath}
stroke="rgba(255, 255, 255, 0.9)"
strokeWidth={2}
d={connectionGeometry.path}
stroke={CONNECTION_STYLE.color}
strokeWidth={CONNECTION_STYLE.strokeWidth}
fill="none"
strokeDasharray="8,4"
strokeDasharray={CONNECTION_STYLE.dashArray}
strokeLinecap="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: 1,
opacity: 1
}}
exit={{ pathLength: 0, opacity: 0 }}
strokeLinejoin="round"
initial={animationConfig.line?.initial}
animate={animationConfig.line?.animate}
exit={animationConfig.line?.initial}
transition={{
...animationConfig.line?.transition,
// 稍微延长显示状态的动画时间
pathLength: { duration: 0.8, ease: "easeOut" },
opacity: { duration: 0.5 }
}}
style={{
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
filter: CONNECTION_STYLE.dropShadow
}}
/>
{/* Arrow head */}
{/* 几何精确的箭头 - 与连接线完美对齐 */}
<motion.polygon
points={`${connectionEnd.x},${connectionEnd.y} ${connectionEnd.x-8},${connectionEnd.y-4} ${connectionEnd.x-8},${connectionEnd.y+4}`}
fill="rgba(255, 255, 255, 0.9)"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ delay: 0.4, duration: 0.3 }}
points={connectionGeometry.arrowPoints.map(p => `${p.x},${p.y}`).join(' ')}
fill={CONNECTION_STYLE.color}
initial={animationConfig.arrow?.initial}
animate={animationConfig.arrow?.animate}
exit={animationConfig.arrow?.initial}
transition={animationConfig.arrow?.transition}
style={{
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
filter: CONNECTION_STYLE.dropShadow
}}
/>
</motion.svg>

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

View File

@ -2,11 +2,13 @@
import React from '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 { useDeviceType } from '@/hooks/useDeviceType';
import 'swiper/css';
import 'swiper/css/effect-coverflow';
import 'swiper/css/effect-cards';
/** 默认视频列表(来自 home-page2.tsx 中的数组) */
const DEFAULT_VIDEOS: string[] = [
@ -40,21 +42,22 @@ const VideoCoverflow: React.FC<VideoCoverflowProps> = ({
}) => {
const swiperRef = React.useRef<SwiperType | null>(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 playActive = React.useCallback((activeIndex: number) => {
Object.entries(videoRefs.current).forEach(([key, el]) => {
if (!el) return;
const video = el as HTMLVideoElement | null;
if (!video) return;
const index = Number(key);
if (index === activeIndex) {
// 尝试播放当前居中视频
el.play().catch(() => {});
video.play().catch(() => {});
} else {
// 暂停其他视频,重置到起点以减少解码负担
el.pause();
video.pause();
try {
el.currentTime = 0;
video.currentTime = 0;
} catch {}
}
});
@ -115,28 +118,31 @@ const VideoCoverflow: React.FC<VideoCoverflowProps> = ({
</p>
<div data-alt="video-coverflow" className="w-screen sm:w-full mx-auto overflow-hidden">
<Swiper
modules={[Autoplay, EffectCoverflow]}
effect="coverflow"
modules={isMobile ? [Autoplay, EffectCards] : [Autoplay, EffectCoverflow]}
effect={isMobile ? 'cards' : 'coverflow'}
key={isMobile ? 'cards' : 'coverflow'}
centeredSlides
slidesPerView={isMobile ? 1 : 2}
loop
autoplay={{ delay: autoplayDelay, disableOnInteraction: false }}
speed={1000}
coverflowEffect={{
{...(!isMobile ? {
coverflowEffect: {
rotate: -56,
stretch: 10,
depth: 80,
scale: 0.6,
modifier: 1,
slideShadows: true,
}}
},
} : {})}
onAfterInit={handleAfterInit}
onSlideChange={handleSlideChange}
className="w-full py-8"
>
{videos.map((src, index) => (
<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
data-alt="video"
ref={(el) => { videoRefs.current[index] = el; }}

View File

@ -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配置接口
@ -14,6 +39,13 @@ export interface SSOConfig {
description: string;
}
/**
*
*/
export interface VideoModificationConfig {
show: boolean;
}
/**
* SSO配置
* @returns Promise<SSOConfig | null>
@ -21,7 +53,7 @@ export interface SSOConfig {
export const getSSOConfig = async (): Promise<SSOConfig | null> => {
try {
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);
@ -86,3 +118,75 @@ export const isGoogleLoginEnabled = async (): Promise<boolean> => {
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
View File

@ -93,6 +93,7 @@
"react-joyride": "^2.9.3",
"react-lazyload": "^3.2.1",
"react-markdown": "^10.1.0",
"react-masonry-css": "^1.0.16",
"react-redux": "^9.2.0",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.3",
@ -17064,6 +17065,15 @@
"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": {
"version": "9.2.0",
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",

View File

@ -98,6 +98,7 @@
"react-joyride": "^2.9.3",
"react-lazyload": "^3.2.1",
"react-markdown": "^10.1.0",
"react-masonry-css": "^1.0.16",
"react-redux": "^9.2.0",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.3",

View File

@ -73,12 +73,18 @@ module.exports = {
filter: "url(#toggle-glass) blur(2px)",
transform: "scale(1)",
}
},
wiggle: {
'0%, 60%': { transform: 'rotate(0deg)' },
'70%, 90%': { transform: 'rotate(-6deg)' },
'80%': { transform: 'rotate(6deg)' },
}
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"liquid-toggle": "liquid-toggle 2s ease-in-out infinite",
"wiggle": "wiggle 1s ease-in-out infinite",
},
transitionDelay: {
'100': '100ms',

View File

@ -110,10 +110,10 @@ export const downloadAllVideos = async (urls: string[]) => {
* aliyuncs.com
* @param url URL
*/
export const getFirstFrame = (url: string) => {
export const getFirstFrame = (url: string, width?: number) => {
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 {
return url + '?vframe/jpg/offset/1';
return url + '?vframe/jpg/offset/1' + `${width ? '/w/'+width : ''}`;
}
}