diff --git a/apps/web/src/api/products/products.ts b/apps/web/src/api/products/products.ts index 18680df7b..95de308ad 100644 --- a/apps/web/src/api/products/products.ts +++ b/apps/web/src/api/products/products.ts @@ -1,6 +1,6 @@ /** * 成品 / 视频相关 API 函数 - * 后端实际接口:/videos + * 后端实际接口:/videos(分页:page/page_size,返回 {items, total, page, page_size}) */ import apiClient from "../client" import type { @@ -12,16 +12,39 @@ import type { } from "./types" import { mapVideoToProductItem } from "./utils" -/** 获取成品列表(支持分页和筛选) */ -export const getProducts = async (params?: ProductListParams): Promise => { - const response = await apiClient.get("/videos", { params }) - const data = response.data - const videos: VideoItem[] = Array.isArray(data?.items) - ? data.items - : Array.isArray(data) - ? data - : [] - return videos.map(mapVideoToProductItem) +/** 分页列表响应(前端消费用) */ +export interface ProductListResult { + items: ProductItem[] + total: number + page: number + page_size: number +} + +/** + * 获取成品列表(分页) + * @param params 分页与筛选参数:page 默认 1,page_size 默认 20 + */ +export const getProducts = async (params?: ProductListParams): Promise => { + const response = await apiClient.get("/videos", { + params: { + page: 1, + page_size: 20, + ...params, + }, + }) + const data = response.data as { + items?: VideoItem[] + total?: number + page?: number + page_size?: number + } + const items: VideoItem[] = Array.isArray(data?.items) ? data.items : [] + return { + items: items.map(mapVideoToProductItem), + total: data.total ?? items.length, + page: data.page ?? params?.page ?? 1, + page_size: data.page_size ?? params?.page_size ?? 20, + } } /** 获取单个成品详情 */ diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 8ca9a999d..9f7e125db 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -25,6 +25,7 @@ import { getRenderJob, generateSmartCover, } from "./api/aiAvatar" +import { getOrCreateDefaultProject } from "@/api/projects" import { normalizeEmotion, buildTitleConfigPayload, @@ -206,9 +207,12 @@ const AiAvatarPage: React.FC = () => { } state.setIsGenerating(true) try { + // 确保有 project_id(AI数字人入口独立,不在项目内,自动取默认项目;#1860 P0 bugfix) + const defaultProject = await getOrCreateDefaultProject() const job = await submitRender({ lipsync_job_id: state.lipsyncJob.id, script_id: state.script?.id, + project_id: defaultProject.id, b_roll_segments: state.bRollSegments.map((seg) => ({ script_segment_index: seg.script_segment_index, asset_url: seg.asset.file_url || "", @@ -441,10 +445,10 @@ const AiAvatarPage: React.FC = () => {
state.setCoverConfig((prev) => ({ ...prev, ...partial })) } + titleConfig={state.titleConfig} onSmartCover={handleSmartCover} smartCoverLoading={smartCoverLoading} canSmartCover={state.lipsyncJob?.status === "completed"} diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx index d15cacc9c..dcddcef41 100644 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -1,17 +1,22 @@ /** * 成片库页面 — V21 设计系统 - * 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选 + * 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选、无限滚动分页 * * 主组件仅保留 Hook 组装与整体布局 - * 列表查询 → hooks/useProductList + * 列表查询 → hooks/useProductList(useInfiniteQuery 分页) * 操作逻辑 → hooks/useProductActions * 筛选栏 → components/ProductFilterBar * 批量操作栏 → components/ProductBatchBar * 空状态 → components/ProductEmptyState * 产品卡片 → components/ProductCard(内联视频播放) */ -import React from "react" -import { VideoCameraOutlined, DownloadOutlined, ReloadOutlined } from "@ant-design/icons" +import React, { useEffect, useRef } from "react" +import { + VideoCameraOutlined, + DownloadOutlined, + ReloadOutlined, + LoadingOutlined, +} from "@ant-design/icons" import { Button } from "@/components/ui" import { ProductCard } from "./components/ProductCard" import { ProductFilterBar } from "./components/ProductFilterBar" @@ -24,11 +29,13 @@ import "./products.css" const ProductLibrary: React.FC = () => { const { - products, filteredProducts, isLoading, + isFetchingNextPage, isError, error, + hasNextPage, + fetchNextPage, refetch, searchText, setSearchText, @@ -64,19 +71,40 @@ const ProductLibrary: React.FC = () => { } = useProductActions({ selectedIds, clearSelection, - products, + products: filteredProducts, setPlayingProduct: () => {}, // 不再使用弹窗播放 }) const { recomputeDedup, isRecomputing } = useRecomputeDedup() - // ── Loading 状态 ── - if (isLoading) { + /* ── 无限滚动:IntersectionObserver 监听底部哨兵元素 ── */ + const sentinelRef = useRef(null) + + useEffect(() => { + const el = sentinelRef.current + if (!el) return + // 已有数据但正在加载中/没有更多页时不触发 + if (isFetchingNextPage || !hasNextPage) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + void fetchNextPage() + } + }, + { rootMargin: "200px" }, + ) + observer.observe(el) + return () => observer.disconnect() + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) + + // ── Loading 状态(仅首次加载)── + if (isLoading && filteredProducts.length === 0) { return } // ── Error 状态 ── - if (isError) { + if (isError && filteredProducts.length === 0) { console.error("[ProductLibrary] 加载失败:", error) const errorMsg = error?.message || "加载失败" const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found") @@ -143,22 +171,46 @@ const ProductLibrary: React.FC = () => { {/* 卡片网格 */} {filteredProducts.length > 0 ? ( -
- {filteredProducts.map((product) => ( - - ))} -
+ <> +
+ {filteredProducts.map((product) => ( + + ))} +
+ + {/* 底部哨兵 + 状态提示 */} +
+ {isFetchingNextPage ? ( + <> + 加载中… + + ) : hasNextPage ? ( + 加载更多 + ) : ( + —— 已加载全部 —— + )} +
+ ) : ( )} diff --git a/apps/web/src/pages/products/hooks/useProductList/index.ts b/apps/web/src/pages/products/hooks/useProductList/index.ts index 1452a46e5..6729bca21 100644 --- a/apps/web/src/pages/products/hooks/useProductList/index.ts +++ b/apps/web/src/pages/products/hooks/useProductList/index.ts @@ -1,28 +1,53 @@ import { useMemo } from "react" -import { useQuery } from "@tanstack/react-query" +import { useInfiniteQuery } from "@tanstack/react-query" import { getProducts, type ProductItem as ApiProductItem } from "@/api/products" import { mapApiProduct } from "../../utils" +import type { ProductItem } from "../../types" import { useProductFiltering } from "./useProductFiltering" import { useBatchSelection } from "./useBatchSelection" export type { Filters } from "./useProductFiltering" +const PAGE_SIZE = 20 + export const useProductList = () => { - /* ── 获取成品列表 ── */ + /* ── 无限滚动获取成品列表(每页 20 条) ── */ const { - data: apiProducts = [], + data, isLoading, + isFetchingNextPage, isError, error, + hasNextPage, + fetchNextPage, refetch, - } = useQuery({ + } = useInfiniteQuery< + { + items: ApiProductItem[] + total: number + page: number + page_size: number + }, + Error + >({ queryKey: ["products"], - queryFn: () => getProducts(), + queryFn: async ({ pageParam = 1 }) => + getProducts({ page: pageParam as number, page_size: PAGE_SIZE }), + initialPageParam: 1, + getNextPageParam: (lastPage) => { + const loadedCount = lastPage.page * lastPage.page_size + return loadedCount < lastPage.total ? lastPage.page + 1 : undefined + }, staleTime: 30_000, }) - // 映射为前端类型,按创建时间倒序排列,防御非数组返回 - const products = useMemo( + // 将所有页拼接为一维数组,再做前端映射+排序 + const apiProducts = useMemo(() => { + if (!data?.pages) return [] + return data.pages.flatMap((p) => p.items) + }, [data]) + + const products = useMemo( () => (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => { if (!a.date || a.date === "—") return 1 @@ -65,8 +90,11 @@ export const useProductList = () => { products, filteredProducts, isLoading, + isFetchingNextPage, isError, error, + hasNextPage, + fetchNextPage, refetch, // 筛选 searchText,