diff --git a/.env.development b/.env.development
index e37d8cc..215df85 100644
--- a/.env.development
+++ b/.env.development
@@ -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
diff --git a/api/script_episode.ts b/api/script_episode.ts
index cc074fc..490333b 100644
--- a/api/script_episode.ts
+++ b/api/script_episode.ts
@@ -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;
diff --git a/app/activate/page.tsx b/app/activate/page.tsx
index d342139..824beff 100644
--- a/app/activate/page.tsx
+++ b/app/activate/page.tsx
@@ -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."
- );
+ setMessage(
+ "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
{message}
+
+
+ Redirecting to login page in {countdown} seconds...
+
+
+ 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
+
);
diff --git a/app/api/server-setting/find_by_code/route.ts b/app/api/server-setting/find_by_code/route.ts
index dd178ae..6f500b2 100644
--- a/app/api/server-setting/find_by_code/route.ts
+++ b/app/api/server-setting/find_by_code/route.ts
@@ -59,6 +59,23 @@ export async function POST(request: NextRequest) {
updated_at: new Date().toISOString().slice(0, 19)
};
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:
// 默认返回空配置
diff --git a/app/share/page.tsx b/app/share/page.tsx
index d7a454f..ae5ad57 100644
--- a/app/share/page.tsx
+++ b/app/share/page.tsx
@@ -197,36 +197,7 @@ export default function SharePage(): JSX.Element {
Invite friends to join and earn rewards.
-
- {/* Section 1: Invite Flow */}
-
- Invitation Flow
-
-
-
- Step 1
- Share
-
- Copy your invitation link and share it with friends.
-
-
-
- Step 2
- Register
-
- Friends click the link and register directly.
-
-
-
- Step 3
- Reward
-
- You both receive rewards after your friend activates their account.
-
-
-
-
- {/* Section 2: My Invitation Link */}
+ {/* Section 1: My Invitation Link */}
@@ -277,6 +248,74 @@ export default function SharePage(): JSX.Element {
+ {/* Section 2: Invite Flow - Two Columns (Left: Steps, Right: Rules) */}
+
+
+ {/* Left: Steps */}
+
+
Invitation Flow
+
+
+
+ Step 1
+ Share
+
+ Copy your invitation link and share it with friends.
+
+
+
+ Step 2
+ Register
+
+ Friends click the link and register directly.
+
+
+
+ Step 3
+ Reward
+
+ You both receive rewards after your friend activates their account.
+
+
+
+ {/* Right: Rules */}
+
+
MovieFlow Credits Rewards Program
+
+
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.
+
+
+
How to Earn Credits?
+
+
+
Welcome Bonus
+
All new users receive a bonus of 500 credits upon successful registration!
+
+
+
+
Invite & Earn
+
Invite friends to join using your unique referral link. Both you and your friend will get 500 credits once they successfully sign up.
+
+
+
If your invited friend completes their first purchase, you will receive a bonus equal to 20% of the credits they earn from that purchase.
+
+
+
+
+
Daily Login
+
Starting the day after registration, log in daily to claim 100 credits .
+
This reward can be claimed for 7 consecutive days .
+
+
+
Please note: Daily login credits will reset automatically on the 8th day, so remember to use them in time!
+
+
+
+
+
+
+
+
{/* Section 3: Invite Records */}
diff --git a/app/test-server-config/page.tsx b/app/test-server-config/page.tsx
new file mode 100644
index 0000000..9d4b9e6
--- /dev/null
+++ b/app/test-server-config/page.tsx
@@ -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
(null);
+ const [videoModStatus, setVideoModStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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 (
+
+
+
服务器配置测试页面
+
+
+
配置状态
+
+
+
+ Google登录 (sso_config):
+
+ {ssoStatus === null ? '检测中...' : ssoStatus ? '启用' : '禁用'}
+
+
+
+
+ 视频修改 (video_modification):
+
+ {videoModStatus === null ? '检测中...' : videoModStatus ? '启用' : '禁用'}
+
+
+
+
+ {error && (
+
+ )}
+
+
+
+ {loading ? '测试中...' : '重新测试'}
+
+
+ window.location.href = '/movies/work-flow'}
+ className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
+ >
+ 前往Work-Flow页面
+
+
+
+
+
+
API测试信息
+
+
SSO API: POST /api/server-setting/find_by_code {"{ code: 'sso_config' }"}
+
视频修改API: POST /api/server-setting/find_by_code {"{ code: 'video_modification' }"}
+
预期响应格式: {"{ code: 0, successful: true, data: { value: '{\"show\": true}' } }"}
+
+
+
+
+
请打开浏览器开发者工具查看详细的API调用日志
+
+
+
+ );
+}
diff --git a/components/ChatInputBox/ChatInputBox.tsx b/components/ChatInputBox/ChatInputBox.tsx
index 763b7c1..04e219e 100644
--- a/components/ChatInputBox/ChatInputBox.tsx
+++ b/components/ChatInputBox/ChatInputBox.tsx
@@ -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(null);
+ const [isTemplateDragging, setIsTemplateDragging] = useState(false);
+ const [templateStartX, setTemplateStartX] = useState(0);
+ const [templateScrollLeft, setTemplateScrollLeft] = useState(0);
+
+ // 模板快捷入口拖动事件处理
+ const handleTemplateMouseDown = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ setIsTemplateDragging(true);
+ setTemplateStartX(e.pageX - templateScrollRef.current!.offsetLeft);
+ setTemplateScrollLeft(templateScrollRef.current!.scrollLeft);
+ }, []);
+
+ const handleTemplateMouseMove = useCallback((e: React.MouseEvent) => {
+ 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 = (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 }) {
{/* 第三行:模板快捷入口水平滚动 */}
-
+
{isTemplateLoading && (!templateStoryList || templateStoryList.length === 0) ? (
// 骨架屏:若正在加载且没有数据
Array.from({ length: 6 }).map((_, idx) => (
@@ -525,7 +578,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
{
// id 映射:优先使用模板的 id;若需要兼容 template_id,则传两者之一
setInitialTemplateId(tpl.id || (tpl as any).template_id);
diff --git a/components/ChatInputBox/H5PhotoStoryDrawer.tsx b/components/ChatInputBox/H5PhotoStoryDrawer.tsx
index b13e0d4..2e46551 100644
--- a/components/ChatInputBox/H5PhotoStoryDrawer.tsx
+++ b/components/ChatInputBox/H5PhotoStoryDrawer.tsx
@@ -80,7 +80,7 @@ export const H5PhotoStoryDrawer = ({
const { loadingText } = useLoadScriptText(isLoading);
const [localLoading, setLocalLoading] = useState(0);
- const [aspectUI, setAspectUI] = useState("VIDEO_ASPECT_RATIO_LANDSCAPE");
+ const [aspectUI, setAspectUI] = useState("VIDEO_ASPECT_RATIO_PORTRAIT");
const router = useRouter();
const taskProgressRef = useRef(taskProgress);
const [cursorPosition, setCursorPosition] = useState(0);
diff --git a/components/ChatInputBox/H5TemplateDrawer.tsx b/components/ChatInputBox/H5TemplateDrawer.tsx
index 176ad32..5d0b843 100644
--- a/components/ChatInputBox/H5TemplateDrawer.tsx
+++ b/components/ChatInputBox/H5TemplateDrawer.tsx
@@ -79,7 +79,7 @@ export const H5TemplateDrawer = ({
const [isDescExpanded, setIsDescExpanded] = useState(false);
// 自由输入框布局
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
- const [aspectUI, setAspectUI] = useState("VIDEO_ASPECT_RATIO_LANDSCAPE");
+ const [aspectUI, setAspectUI] = useState("VIDEO_ASPECT_RATIO_PORTRAIT");
// 顶部列表所在的实际滚动容器(外层 top-section 才是滚动容器)
const topSectionRef = useRef(null);
diff --git a/components/SmartChatBox/SmartChatBox.tsx b/components/SmartChatBox/SmartChatBox.tsx
index 7ba47fe..e141121 100644
--- a/components/SmartChatBox/SmartChatBox.tsx
+++ b/components/SmartChatBox/SmartChatBox.tsx
@@ -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(null);
@@ -103,6 +106,23 @@ export default function SmartChatBox({
onMessagesUpdate: handleMessagesUpdate
});
+ // 监听消息新增,向外层抛出前10个字符的文本片段
+ const prevLenRef = useRef(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 */}
-
+
{groupedMessages.map((group) => (
diff --git a/components/layout/signin-box.tsx b/components/layout/signin-box.tsx
index 2cee5f7..004f8b7 100644
--- a/components/layout/signin-box.tsx
+++ b/components/layout/signin-box.tsx
@@ -61,10 +61,10 @@ export default function SigninPage() {
if (isInitialLoading) {
return (
-
-
-
-
+
+
+
+
@@ -75,47 +75,53 @@ export default function SigninPage() {
}
return (
-
+
{/* Signin status card */}
-
-
-
+
+
+
Daily Sign-in
-
Sign in to earn credits. Credits are valid for 7 days
-
+
Sign in to earn credits. Credits are valid for 7 days
+
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"
>
{showTip && (
-
+
Sign-in Rules
• Daily sign-in earns 100 credits
• Credits are valid for 7 days
• Expired credits will be automatically cleared
-
+
)}
-
+
-
+
Earned Credits
-
+
{signinData.credits || 0}
@@ -124,9 +130,10 @@ export default function SigninPage() {
{/* Sign-in button */}
{isLoading ? (
diff --git a/components/layout/top-bar.tsx b/components/layout/top-bar.tsx
index c83c405..365fab3 100644
--- a/components/layout/top-bar.tsx
+++ b/components/layout/top-bar.tsx
@@ -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")
}}
>
-
+
{pathname === "/" && (
-
-
-
+
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"
+ >
+
+
+
+
{isLoadingSubscription
? "Loading..."
@@ -568,10 +576,8 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
data-alt="signin-modal"
>
-
-
-
-
+
+
);
diff --git a/components/pages/create-to-video2.tsx b/components/pages/create-to-video2.tsx
index 6147b1c..e71f762 100644
--- a/components/pages/create-to-video2.tsx
+++ b/components/pages/create-to-video2.tsx
@@ -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([]);
+ const [episodeList, setEpisodeList] = useState([]);
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(null);
const [userId, setUserId] = useState(0);
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
+ const masonryRef = useRef(null);
+ interface PreloadedData {
+ page: number;
+ data: {
+ code: number;
+ data: {
+ movie_projects: MovieProject[];
+ total_pages: number;
+ };
+ };
+ }
+
+ const preloadedDataRef = useRef(null);
// 添加一个 ref 来跟踪当前正在加载的页码
const loadingPageRef = useRef(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
- };
-
- const episodeListResponse = await getScriptEpisodeListNew(params);
+ let episodeListResponse;
+
+ // 如果有预加载的数据且页码匹配,直接使用
+ 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 (
-
handleMouseEnter(project.project_id)}
onMouseLeave={() => handleMouseLeave(project.project_id)}
data-alt="project-card"
>
- {/* 视频/图片区域 */}
-
router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
+ {/* 视频/图片区域(使用 aspect_ratio 预设高度) */}
+
router.push(`/movies/work-flow?episodeId=${project.project_id}`)}
+ >
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
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)
}
/>
) : (
-
)}
@@ -247,7 +339,7 @@ export default function CreateToVideo2() {
{/* 底部信息 */}
-
+
@@ -265,7 +357,7 @@ export default function CreateToVideo2() {
-
+
);
};
@@ -282,9 +374,20 @@ export default function CreateToVideo2() {
{episodeList.length > 0 && (
/* 优化的剧集网格 */
-
- {episodeList.map(renderProjectCard)}
-
+ {(() => {
+ const masonryBreakpoints = { default: 5, 1024: 2, 640: 1 };
+ return (
+
+ {episodeList.map(renderProjectCard)}
+
+ );
+ })()}
{/* 加载更多指示器 */}
{isLoadingMore && (
diff --git a/components/pages/home-page2.tsx b/components/pages/home-page2.tsx
index 0eb4bba..f75a0c4 100644
--- a/components/pages/home-page2.tsx
+++ b/components/pages/home-page2.tsx
@@ -276,7 +276,7 @@ export function HomePage2() {
{/* 动态锚点:来源于服务端 homeTab 配置,title 作为锚点与标题 */}
{homeTabs.map((tab) => (
-
(sectionRefs.current as any)[tab.title.toLowerCase()] = el}>
+
(sectionRefs.current as any)[tab.title.toLowerCase()] = el}>
))}
diff --git a/components/pages/work-flow.tsx b/components/pages/work-flow.tsx
index 95e5fa1..cecf083 100644
--- a/components/pages/work-flow.tsx
+++ b/components/pages/work-flow.tsx
@@ -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
(null);
+ const [hasUnread, setHasUnread] = React.useState(false);
const [previewVideoUrl, setPreviewVideoUrl] = React.useState(null);
const [previewVideoId, setPreviewVideoId] = React.useState(null);
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
@@ -617,15 +619,30 @@ Please process this video editing request.`;
{/* 智能对话按钮 */}
{isMobile ? (
-
setIsSmartChatBoxOpen(true)}
- className="backdrop-blur-lg"
- />
+
+ {(!isSmartChatBoxOpen && chatTip) && (
+
+ {chatTip}
+
+ )}
+ {/* 红点徽标 */}
+ {(!isSmartChatBoxOpen && hasUnread) && (
+
+ )}
+
{
+ setIsSmartChatBoxOpen(true);
+ setChatTip(null);
+ setHasUnread(false);
+ }}
+ className="backdrop-blur-lg bg-custom-purple/80 border-transparent hover:bg-custom-purple/80"
+ />
+
) : (
{
+ if (!isSmartChatBoxOpen && snippet) {
+ setChatTip(snippet);
+ setHasUnread(true);
+ // 5秒后自动消失
+ setTimeout(() => setChatTip(null), 5000);
+ }
+ }}
onClearPreview={() => {
setPreviewVideoUrl(null);
setPreviewVideoId(null);
diff --git a/components/pages/work-flow/H5MediaViewer.tsx b/components/pages/work-flow/H5MediaViewer.tsx
index 8a5237f..6ddcf36 100644
--- a/components/pages/work-flow/H5MediaViewer.tsx
+++ b/components/pages/work-flow/H5MediaViewer.tsx
@@ -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 && (
-
+
{stage === 'video' && (
<>
{
@@ -416,7 +416,7 @@ export function H5MediaViewer({
/>
= ({
return null
}, [selectedView, taskObject, displayCurrent, total])
+ /** 阶段图标(H5 精简版) */
+ const StageIcon = useMemo(() => {
+ const Icon = stageIconMap[currentStage].icon
+ return (
+
+
+
+
+
+ )
+ }, [currentStage, stageColor])
+
return (
= ({
>
-
+ {/* 左侧标题区域 */}
+
= ({
{subtitle}
)}
-
+
+
+ {/* 右侧状态区域 */}
+
+
+ {currentLoadingText && currentLoadingText !== 'Task completed' && (
+
+
+ {StageIcon}
+
+
+ {/* 背景流光 */}
+
+ {currentLoadingText}
+
+
+ {/* 主文字轻微律动 */}
+
+
+
+ {/* 底部装饰线 */}
+
+
+
+ )}
+
diff --git a/components/pages/work-flow/media-viewer.tsx b/components/pages/work-flow/media-viewer.tsx
index 0cafa86..501db9d 100644
--- a/components/pages/work-flow/media-viewer.tsx
+++ b/components/pages/work-flow/media-viewer.tsx
@@ -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("❌ MediaViewer:Failed 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({
- {/* 视频编辑模式切换按钮 - 临时注释 */}
- {/* {enableVideoEdit && (
+ {/* 视频编辑模式切换按钮 - 通过服务器配置控制显示 */}
+ {enableVideoEdit && showVideoModification && (
setIsVideoEditMode(!isVideoEditMode)}
+ onClick={() => {
+ console.log('🖊️ 钢笔图标被点击,切换编辑模式:', !isVideoEditMode);
+ setIsVideoEditMode(!isVideoEditMode);
+ }}
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
/>
- )} */}
+ )}
{/* 添加到chat去编辑 按钮 */}
{
diff --git a/components/pages/work-flow/video-edit/EditConnection.tsx b/components/pages/work-flow/video-edit/EditConnection.tsx
index 6e561ad..9466387 100644
--- a/components/pages/work-flow/video-edit/EditConnection.tsx
+++ b/components/pages/work-flow/video-edit/EditConnection.tsx
@@ -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 = ({
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 (
= ({
height={containerSize.height}
style={{ zIndex: 10 }}
>
- {/* Curved dashed line - properly aligned to arrow base center */}
+ {/* 统一的虚线连接线 - 精确连接到箭头中心 */}
= ({
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 */}
+ {/* 几何精确的箭头 - 与连接线完美对齐 */}
`${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
}}
/>
diff --git a/components/pages/work-flow/video-edit/EditDescription.tsx b/components/pages/work-flow/video-edit/EditDescription.tsx
index c2ea896..37a5034 100644
--- a/components/pages/work-flow/video-edit/EditDescription.tsx
+++ b/components/pages/work-flow/video-edit/EditDescription.tsx
@@ -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 = ({
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 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 arrowGeometry = calculateArrowGeometry(startPoint, endPoint);
- 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 = () => {
@@ -101,7 +113,7 @@ export const EditDescription: React.FC = ({
{editPoint.description && editPoint.status !== EditPointStatus.PENDING && (
<>
- {/* White dashed connection line to match reference image */}
+ {/* 统一的虚线连接线 - 与EditConnection完全一致 */}
= ({
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
+ {/* 统一的虚线连接线 - 与EditConnection完全一致 */}
- {/* Arrow head */}
+ {/* 几何精确的箭头 - 与连接线完美对齐 */}
`${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
}}
/>
diff --git a/components/pages/work-flow/video-edit/connection-config.ts b/components/pages/work-flow/video-edit/connection-config.ts
new file mode 100644
index 0000000..de99804
--- /dev/null
+++ b/components/pages/work-flow/video-edit/connection-config.ts
@@ -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;
+ animate: Record;
+ transition: Record;
+ };
+ arrow: {
+ initial: Record;
+ animate: Record;
+ transition: Record;
+ };
+}
+
+/**
+ * 获取统一的动画配置
+ */
+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
+ }
+ }
+ };
+}
diff --git a/components/ui/VideoCoverflow.tsx b/components/ui/VideoCoverflow.tsx
index b64b350..0fa17d8 100644
--- a/components/ui/VideoCoverflow.tsx
+++ b/components/ui/VideoCoverflow.tsx
@@ -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 = ({
}) => {
const swiperRef = React.useRef(null);
const videoRefs = React.useRef>({});
- const [isMobile, setIsMobile] = React.useState(false);
+ const { isMobile } = useDeviceType();
const [activeIndex, setActiveIndex] = React.useState(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 = ({
{videos.map((src, index) => (
-
+
{ videoRefs.current[index] = el; }}
diff --git a/lib/server-config.ts b/lib/server-config.ts
index c27e7e4..01767ee 100644
--- a/lib/server-config.ts
+++ b/lib/server-config.ts
@@ -2,7 +2,32 @@
* 服务端配置工具函数
*/
-import { post } from '@/api/request';
+// 注意:这里不使用 @/api/request 中的 post 函数,因为它会将请求发送到远程服务器
+// 我们需要直接调用本地的 Next.js API 路由
+
+/**
+ * 本地API请求函数 - 直接调用Next.js API路由
+ */
+const localPost = async (url: string, data: any): Promise => {
+ 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
@@ -21,7 +53,7 @@ export interface SSOConfig {
export const getSSOConfig = async (): Promise => {
try {
console.log('🔍 开始获取SSO配置...');
- const res = await post(`/api/server-setting/find_by_code`, { code: 'sso_config' });
+ const res = await localPost(`/api/server-setting/find_by_code`, { code: 'sso_config' });
console.log('📋 SSO API响应:', res);
@@ -64,21 +96,21 @@ export const isGoogleLoginEnabled = async (): Promise => {
console.log('🔍 检查Google登录是否启用...');
const config = await getSSOConfig();
console.log('📋 获得的配置:', config);
-
+
if (!config) {
console.log('❌ 没有获得配置,返回false');
return false;
}
-
+
const isEnabled = config?.show === true;
-
+
console.log('🔍 配置检查:', {
show: config?.show,
provider: config?.provider,
isEnabled,
finalResult: isEnabled
});
-
+
// 简化逻辑:只检查show字段,因为sso_config专门用于Google登录
return isEnabled;
} catch (error) {
@@ -86,3 +118,75 @@ export const isGoogleLoginEnabled = async (): Promise => {
return false; // 出错时默认不显示
}
};
+
+/**
+ * 获取视频修改配置
+ * @returns Promise
+ */
+export const getVideoModificationConfig = async (): Promise => {
+ try {
+ console.log('🔍 开始获取视频修改配置...');
+ const res = await localPost(`/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
+ */
+export const isVideoModificationEnabled = async (): Promise => {
+ 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; // 出错时默认不显示
+ }
+};
diff --git a/package-lock.json b/package-lock.json
index 450a06c..339e283 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index f8f0d62..174a5a9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/tailwind.config.js b/tailwind.config.js
index c408d20..d01ae8a 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -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',
diff --git a/utils/tools.ts b/utils/tools.ts
index a6f9a93..9910de9 100644
--- a/utils/tools.ts
+++ b/utils/tools.ts
@@ -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 : ''}`;
}
}
\ No newline at end of file