瀑布流布局

This commit is contained in:
北枳 2025-09-24 20:57:09 +08:00
parent 272cc8ba41
commit 17d57092be
5 changed files with 152 additions and 37 deletions

View File

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

View File

@ -5,13 +5,14 @@ import { Loader2, Download } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import './style/create-to-video2.css'; import './style/create-to-video2.css';
import { getScriptEpisodeListNew } from "@/api/script_episode"; import { getScriptEpisodeListNew, MovieProject } from "@/api/script_episode";
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox'; import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
import cover_image1 from '@/public/assets/cover_image3.jpg'; import cover_image1 from '@/public/assets/cover_image3.jpg';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Tooltip, Button } from 'antd'; import { Tooltip, Button } from 'antd';
import { downloadVideo, getFirstFrame } from '@/utils/tools'; import { downloadVideo, getFirstFrame } from '@/utils/tools';
import LazyLoad from "react-lazyload"; import Masonry from 'react-masonry-css';
import debounce from 'lodash/debounce';
@ -19,35 +20,75 @@ import LazyLoad from "react-lazyload";
export default function CreateToVideo2() { export default function CreateToVideo2() {
const router = useRouter(); const router = useRouter();
const [episodeList, setEpisodeList] = useState<any[]>([]); const [episodeList, setEpisodeList] = useState<MovieProject[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [perPage] = useState(12); const [perPage] = useState(28);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isPreloading, setIsPreloading] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [userId, setUserId] = useState<number>(0); const [userId, setUserId] = useState<number>(0);
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false); const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
const masonryRef = useRef<any>(null);
interface PreloadedData {
page: number;
data: {
code: number;
data: {
movie_projects: MovieProject[];
total_pages: number;
};
};
}
const preloadedDataRef = useRef<PreloadedData | null>(null);
// 添加一个 ref 来跟踪当前正在加载的页码 // 添加一个 ref 来跟踪当前正在加载的页码
const loadingPageRef = useRef<number | null>(null); const loadingPageRef = useRef<number | null>(null);
// 在客户端挂载后读取localStorage // 在客户端挂载后读取localStorage
// 监听滚动事件,实现无限加载 // 预加载下一页数据
// 修改滚动处理函数,添加节流 const preloadNextPage = async (userId: number, page: number) => {
if (isPreloading || !hasMore || page > totalPages) return;
setIsPreloading(true);
try {
const response = await fetchEpisodeData(userId, page);
if (response.code === 0) {
preloadedDataRef.current = {
page,
data: response
};
}
} catch (error) {
console.error('Failed to preload next page:', error);
} finally {
setIsPreloading(false);
}
};
// 监听滚动事件,实现无限加载和预加载
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return; if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
if (scrollHeight - scrollTop - clientHeight < 100) { const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// 直接使用 currentPage不再使用 setCurrentPage 的回调
// 在滚动到 30% 时预加载下一页
// if (scrollPercentage > 0.30 && !isPreloading && currentPage < totalPages) {
// preloadNextPage(userId, currentPage + 1);
// }
// 在滚动到 70% 时加载下一页
if (scrollPercentage > 0.7) {
const nextPage = currentPage + 1; const nextPage = currentPage + 1;
if (nextPage <= totalPages) { if (nextPage <= totalPages) {
getEpisodeList(userId, nextPage, true); getEpisodeList(userId, nextPage, true);
} }
} }
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage]); }, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage, isPreloading]);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -67,6 +108,16 @@ export default function CreateToVideo2() {
} }
}, [handleScroll]); }, [handleScroll]);
// 获取剧集列表数据
const fetchEpisodeData = async (userId: number, page: number) => {
const params = {
user_id: String(userId),
page,
per_page: perPage
};
return await getScriptEpisodeListNew(params);
};
// 修改获取剧集列表函数 // 修改获取剧集列表函数
const getEpisodeList = async (userId: number, page: number = 1, loadMore: boolean = false) => { const getEpisodeList = async (userId: number, page: number = 1, loadMore: boolean = false) => {
// 检查是否正在加载该页 // 检查是否正在加载该页
@ -83,13 +134,15 @@ export default function CreateToVideo2() {
} }
try { try {
const params = { let episodeListResponse;
user_id: String(userId),
page, // 如果有预加载的数据且页码匹配,直接使用
per_page: perPage if (preloadedDataRef.current && preloadedDataRef.current.page === page) {
}; episodeListResponse = preloadedDataRef.current.data;
preloadedDataRef.current = null;
const episodeListResponse = await getScriptEpisodeListNew(params); } else {
episodeListResponse = await fetchEpisodeData(userId, page);
}
if (episodeListResponse.code === 0) { if (episodeListResponse.code === 0) {
const { movie_projects, total_pages } = episodeListResponse.data; const { movie_projects, total_pages } = episodeListResponse.data;
@ -109,6 +162,11 @@ export default function CreateToVideo2() {
setTotalPages(total_pages); setTotalPages(total_pages);
setHasMore(page < total_pages); setHasMore(page < total_pages);
setCurrentPage(page); setCurrentPage(page);
// 预加载下一页数据
// if (page < total_pages && !isPreloading) {
// preloadNextPage(userId, page + 1);
// }
} }
} catch (error) { } catch (error) {
@ -188,38 +246,72 @@ export default function CreateToVideo2() {
} }
}; };
const renderProjectCard = (project: any) => { // 监听窗口大小变化,触发 Masonry 重排
useEffect(() => {
const handleResize = debounce(() => {
if (masonryRef.current?.recomputeCellPositions) {
masonryRef.current.recomputeCellPositions();
}
}, 200);
window.addEventListener('resize', handleResize);
return () => {
handleResize.cancel();
window.removeEventListener('resize', handleResize);
};
}, []);
const renderProjectCard = (project: MovieProject): JSX.Element => {
// 根据 aspect_ratio 计算纵横比
const getAspectRatio = () => {
switch (project.aspect_ratio) {
case "VIDEO_ASPECT_RATIO_LANDSCAPE":
return 16 / 9; // 横屏 16:9
case "VIDEO_ASPECT_RATIO_PORTRAIT":
return 9 / 16; // 竖屏 9:16
default:
return 16 / 9; // 默认横屏
}
};
const aspectRatio = getAspectRatio();
return ( return (
<LazyLoad key={project.project_id} once>
<div <div
key={project.project_id} key={project.project_id}
className="group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300" className="relative group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
onMouseEnter={() => handleMouseEnter(project.project_id)} onMouseEnter={() => handleMouseEnter(project.project_id)}
onMouseLeave={() => handleMouseLeave(project.project_id)} onMouseLeave={() => handleMouseLeave(project.project_id)}
data-alt="project-card" data-alt="project-card"
> >
{/* 视频/图片区域 */} {/* 视频/图片区域(使用 aspect_ratio 预设高度) */}
<div className="relative w-full pb-[56.25%]" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}> <div
className="relative w-full"
style={{
aspectRatio: aspectRatio
}}
onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}
>
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? ( {(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
<video <video
ref={(el) => setVideoRef(project.project_id, el)} ref={(el) => setVideoRef(project.project_id, el)}
src={project.final_video_url || project.final_simple_video_url || project.video_urls} src={project.final_video_url || project.final_simple_video_url || project.video_urls || ''}
className="absolute inset-0 w-full h-full object-contain group-hover:scale-105 transition-transform duration-500" className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
muted muted
loop loop
playsInline playsInline
preload="none" preload="auto"
poster={ poster={
getFirstFrame(project.final_video_url || project.final_simple_video_url || project.video_urls) getFirstFrame(project.final_video_url || project.final_simple_video_url || project.video_urls || '', 300)
} }
/> />
) : ( ) : (
<div <div
className="absolute inset-0 w-full h-full bg-contain bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500" className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
style={{ style={{
backgroundImage: `url(${cover_image1.src})`, backgroundImage: `url(${cover_image1.src})`,
}} }}
data-alt="cover-image"
/> />
)} )}
@ -247,7 +339,7 @@ export default function CreateToVideo2() {
</div> </div>
{/* 底部信息 */} {/* 底部信息 */}
<div className="p-4 group"> <div className="p-2 group absolute bottom-0 left-0 right-0 bg-black/10 backdrop-blur-lg rounded-b-lg hidden group-hover:block">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-sm font-medium text-white line-clamp-1"> <h2 className="text-sm font-medium text-white line-clamp-1">
@ -265,7 +357,7 @@ export default function CreateToVideo2() {
</div> </div>
</div> </div>
</div> </div>
</LazyLoad>
); );
}; };
@ -282,9 +374,20 @@ export default function CreateToVideo2() {
{episodeList.length > 0 && ( {episodeList.length > 0 && (
/* 优化的剧集网格 */ /* 优化的剧集网格 */
<div className="pb-8"> <div className="pb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> {(() => {
{episodeList.map(renderProjectCard)} const masonryBreakpoints = { default: 5, 1024: 2, 640: 1 };
</div> return (
<Masonry
ref={masonryRef}
breakpointCols={masonryBreakpoints}
className="flex -ml-2"
columnClassName="pl-2 space-y-2"
data-alt="masonry-grid"
>
{episodeList.map(renderProjectCard)}
</Masonry>
);
})()}
{/* 加载更多指示器 */} {/* 加载更多指示器 */}
{isLoadingMore && ( {isLoadingMore && (

10
package-lock.json generated
View File

@ -93,6 +93,7 @@
"react-joyride": "^2.9.3", "react-joyride": "^2.9.3",
"react-lazyload": "^3.2.1", "react-lazyload": "^3.2.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-masonry-css": "^1.0.16",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.3", "react-resizable-panels": "^2.1.3",
@ -17064,6 +17065,15 @@
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/react-masonry-css": {
"version": "1.0.16",
"resolved": "https://registry.npmmirror.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz",
"integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.0.0"
}
},
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",

View File

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

View File

@ -110,10 +110,10 @@ export const downloadAllVideos = async (urls: string[]) => {
* aliyuncs.com * aliyuncs.com
* @param url URL * @param url URL
*/ */
export const getFirstFrame = (url: string) => { export const getFirstFrame = (url: string, width?: number) => {
if (url.includes('aliyuncs.com')) { if (url.includes('aliyuncs.com')) {
return url + '?x-oss-process=video/snapshot,t_1000,f_jpg'; return url + '?x-oss-process=video/snapshot,t_1000,f_jpg' + `${width ? ',w_'+width : ''}`;
} else { } else {
return url + '?vframe/jpg/offset/1'; return url + '?vframe/jpg/offset/1' + `${width ? '/w/'+width : ''}`;
} }
} }