From 96bf62b00cc4dddc1761090b70a603f72264854a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 11 Sep 2026 18:23:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(P0):=20=E6=88=90=E5=93=81=E5=BA=93?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=8A=A0=E8=BD=BD=E6=9B=B4=E5=A4=9A=20+=20AI?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E4=BA=BA=E6=B8=B2=E6=9F=93=E4=BC=A0=20projec?= =?UTF-8?q?t=5Fid=20=E5=85=A5=E5=BA=93=20(#1862)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/api/products/products.ts | 45 ++++++-- apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 6 +- .../web/src/pages/products/ProductLibrary.tsx | 102 +++++++++++++----- .../products/hooks/useProductList/index.ts | 42 ++++++-- 4 files changed, 151 insertions(+), 44 deletions(-) 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, -- 2.54.0 From 3dcf1079a9e4a592c8213cf7b290938728de3419 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 11 Sep 2026 19:38:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ai-avatar):=20=E5=B0=81=E9=9D=A2title?= =?UTF-8?q?=5Fconfig=E5=AD=97=E6=AE=B5=E5=90=8D=E4=B8=8E=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 封面接口传入的titleConfig用buildTitleConfigPayload转换: - 前端字段 title -> 后端 text - 前端字段 color -> 后端 font_color (#开头保留) - 前端字段 size -> 后端 font_size - 前端字段 position 默认bottom 之前直接传原始AiAvatarTitleConfig对象,后端只认text/font_color/font_size, 导致封面标题内容、颜色、字号、位置全部取默认值(白色居中36px),与对口型预览不一致。 渲染提交路径已经用了buildTitleConfigPayload,是封面路径漏了转换。 --- apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 2 +- apps/web/src/pages/ai-avatar/api/aiAvatar.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 1ffab7c15..e9b244335 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -283,7 +283,7 @@ const AiAvatarPage: React.FC = () => { } setSmartCoverLoading(true) try { - const res = await generateSmartCover(videoUrl, state.titleConfig, 5) + const res = await generateSmartCover(videoUrl, buildTitleConfigPayload(state.titleConfig), 5) if (res.cover_url) { state.setCoverConfig((prev) => ({ ...prev, diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index 276c87372..3af8d7fba 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -2,7 +2,7 @@ * AI数字人 — API 封装(#1822 契约对齐) */ import apiClient from "@/api/client" -import type { Script, LipsyncJob, RenderJob, BRollSegment, AiAvatarTitleConfig } from "../types" +import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types" /* ── 文案库 ── */ export const getScripts = async (): Promise => { @@ -61,7 +61,7 @@ export const getLipsyncJob = async (id: string): Promise => { /* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧 + 可选 drawtext 标题叠加) ── */ export const generateSmartCover = async ( video_url: string, - title_config?: AiAvatarTitleConfig | null, + title_config?: Record | null, max_frames = 5, ): Promise<{ cover_url: string; status: string; message: string }> => { const response = await apiClient.post<{ cover_url: string; status: string; message: string }>( -- 2.54.0