diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts index 8b5dc4375..0e89d4926 100644 --- a/apps/web/src/api/products.ts +++ b/apps/web/src/api/products.ts @@ -1,9 +1,12 @@ /** - * 成品相关 API - * Phase 1 重构:去掉 projectId,成品直接归属用户 + * 成品 / 视频相关 API + * 包含:列表查询、复核状态、批量下载 */ import apiClient from "./client"; +/** 复核状态 */ +export type ReviewStatus = "pending_review" | "approved" | "rejected"; + /** 成品条目 */ export interface ProductItem { id: string; @@ -14,15 +17,49 @@ export interface ProductItem { file_size?: number; resolution?: string; status: "processing" | "completed" | "failed"; + /** 复核状态 */ + review_status?: ReviewStatus; + /** 所属项目 ID */ + project_id?: string; + /** 所属项目名称 */ + project_name?: string; /** 查重率(百分比) */ duplicate_rate?: number; created_at?: string; updated_at?: string; } -/** 获取当前用户的所有成品 */ -export const getProducts = async (): Promise => { - const response = await apiClient.get("/products"); +/** 列表查询参数 */ +export interface ProductListParams { + page?: number; + page_size?: number; + project_id?: string; + review_status?: ReviewStatus | "all"; +} + +/** 分页响应 */ +export interface ProductListResponse { + items: ProductItem[]; + total: number; + page: number; + page_size: number; +} + +/** 批量下载任务状态 */ +export interface BatchDownloadStatus { + job_id: string; + status: "processing" | "completed" | "failed"; + /** 完成后返回的下载 URL */ + download_url?: string; + /** 进度百分比 */ + progress?: number; +} + +/** 获取成品列表(支持分页和筛选) */ +export const getProducts = async ( + params?: ProductListParams, +): Promise => { + const response = await apiClient.get("/products", { params }); return response.data.items || response.data || []; }; @@ -44,3 +81,32 @@ export const getProductDownloadUrl = async ( const response = await apiClient.get(`/products/${productId}/download-url`); return response.data; }; + +/** 更新复核状态 */ +export const updateReviewStatus = async ( + productId: string, + status: ReviewStatus, +): Promise => { + const response = await apiClient.patch(`/products/${productId}/review`, { + status, + }); + return response.data; +}; + +/** 发起批量下载 */ +export const batchDownload = async ( + videoIds: string[], +): Promise<{ job_id: string }> => { + const response = await apiClient.post("/products/batch-download", { + video_ids: videoIds, + }); + return response.data; +}; + +/** 查询批量下载状态 */ +export const getBatchDownloadStatus = async ( + jobId: string, +): Promise => { + const response = await apiClient.get(`/products/batch-download/${jobId}`); + return response.data; +}; diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx index ae02e827b..4ab5dbc00 100644 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -31,7 +31,11 @@ import { getProducts, deleteProduct, getProductDownloadUrl, + updateReviewStatus, + batchDownload, + getBatchDownloadStatus, type ProductItem as ApiProductItem, + type ReviewStatus, } from "@/api/products"; import "./products.css"; @@ -53,6 +57,12 @@ interface ProductItem { fileSize: number; // MB videoUrl?: string; thumbnailUrl?: string; + /** 复核状态 */ + reviewStatus?: ReviewStatus; + /** 所属项目 ID */ + projectId?: string; + /** 所属项目名称 */ + projectName?: string; } /** 将后端 status 映射为前端 ProductStatus */ @@ -108,6 +118,9 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({ fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0, videoUrl: item.video_url, thumbnailUrl: item.thumbnail_url, + reviewStatus: item.review_status, + projectId: item.project_id, + projectName: item.project_name, }); /* ============================================================ @@ -135,6 +148,30 @@ const formatSize = (mb: number): string => { return `${mb.toFixed(1)} MB`; }; +/** 复核状态配置 */ +const reviewStatusConfig: Record< + ReviewStatus, + { text: string; className: string } +> = { + pending_review: { text: "待复核", className: "review-pending" }, + approved: { text: "已通过", className: "review-approved" }, + rejected: { text: "需修改", className: "review-rejected" }, +}; + +/** 复核状态循环顺序 */ +const REVIEW_STATUS_CYCLE: ReviewStatus[] = [ + "pending_review", + "approved", + "rejected", +]; + +/** 获取下一个复核状态 */ +const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => { + if (!current) return "approved"; + const idx = REVIEW_STATUS_CYCLE.indexOf(current); + return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]; +}; + /* ============================================================ * ProductCard 组件 * ============================================================ */ @@ -148,6 +185,7 @@ const ProductCard: React.FC<{ onShare: (product: ProductItem) => void; onDelete: (id: string) => void; onPublish: (product: ProductItem) => void; + onReviewStatusChange: (id: string) => void; }> = ({ product, isSelected, @@ -158,6 +196,7 @@ const ProductCard: React.FC<{ onShare, onDelete, onPublish, + onReviewStatusChange, }) => { const st = statusConfig[product.status]; @@ -201,6 +240,33 @@ const ProductCard: React.FC<{ {/* 已发布徽章(右上角) */} {product.isPublished &&
✅ 已发布
} + {/* 复核状态标签(右上角) */} + {product.reviewStatus && ( +
{ + e.stopPropagation(); + onReviewStatusChange(product.id); + }} + title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`} + > + {reviewStatusConfig[product.reviewStatus].text} +
+ )} + {/* 无复核状态时显示"待复核"入口 */} + {!product.reviewStatus && ( +
{ + e.stopPropagation(); + onReviewStatusChange(product.id); + }} + title="点击设置复核状态" + > + 待复核 +
+ )} + {/* 缩略图 */}
{product.thumbnailUrl ? ( @@ -517,7 +583,7 @@ const ProductLibrary: React.FC = () => { refetch, } = useQuery({ queryKey: ["products"], - queryFn: getProducts, + queryFn: () => getProducts(), staleTime: 30_000, }); @@ -536,11 +602,26 @@ const ProductLibrary: React.FC = () => { }, }); + /* ── 复核状态 mutation ── */ + const reviewMutation = useMutation({ + mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) => + updateReviewStatus(id, status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }); + message.success("复核状态已更新"); + }, + onError: () => { + message.error("更新复核状态失败"); + }, + }); + /* 筛选 */ const [searchText, setSearchText] = useState(""); const [filterStatus, setFilterStatus] = useState("all"); const [filterTime, setFilterTime] = useState("all"); const [filterDuration, setFilterDuration] = useState("all"); + const [filterProject, setFilterProject] = useState("all"); + const [filterReviewStatus, setFilterReviewStatus] = useState("all"); /* 批量操作 */ const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -596,6 +677,20 @@ const ProductLibrary: React.FC = () => { }); } + /* 项目筛选 */ + if (filterProject !== "all") { + list = list.filter((p) => p.projectId === filterProject); + } + + /* 复核状态筛选 */ + if (filterReviewStatus !== "all") { + if (filterReviewStatus === "none") { + list = list.filter((p) => !p.reviewStatus); + } else { + list = list.filter((p) => p.reviewStatus === filterReviewStatus); + } + } + /* 搜索 */ if (searchText.trim()) { const q = searchText.trim().toLowerCase(); @@ -603,7 +698,15 @@ const ProductLibrary: React.FC = () => { } return list; - }, [products, filterStatus, filterTime, filterDuration, searchText]); + }, [ + products, + filterStatus, + filterTime, + filterDuration, + filterProject, + filterReviewStatus, + searchText, + ]); /* 全选 */ const allSelected = @@ -679,24 +782,56 @@ const ProductLibrary: React.FC = () => { message.info("发布功能待后端 API 补齐"); }; - /* 批量下载 */ + /* 切换复核状态 */ + const handleReviewStatusChange = (id: string) => { + const current = products.find((p) => p.id === id)?.reviewStatus; + const nextStatus = getNextReviewStatus(current); + reviewMutation.mutate({ id, status: nextStatus }); + }; + + /* 批量下载 — 使用 batch-download API + 轮询 */ + const [batchDownloading, setBatchDownloading] = useState(false); + const handleBatchDownload = async () => { const ids = Array.from(selectedIds); - let successCount = 0; - for (const id of ids) { - try { - const { url } = await getProductDownloadUrl(id); - const a = document.createElement("a"); - a.href = url; - a.download = ""; - a.click(); - successCount++; - } catch { - // 忽略单个失败 - } + if (ids.length === 0) return; + setBatchDownloading(true); + try { + // 发起批量下载任务 + const { job_id } = await batchDownload(ids); + message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`); + + // 轮询下载状态(最多 60 次,每次 2 秒) + let attempts = 0; + const maxAttempts = 60; + const poll = async (): Promise => { + if (attempts >= maxAttempts) { + message.warning("打包超时,请稍后在消息中心查看"); + return; + } + attempts++; + await new Promise((r) => setTimeout(r, 2000)); + const status = await getBatchDownloadStatus(job_id); + if (status.status === "completed" && status.download_url) { + const a = document.createElement("a"); + a.href = status.download_url; + a.download = ""; + a.click(); + message.success(`已打包下载 ${ids.length} 个视频`); + setSelectedIds(new Set()); + } else if (status.status === "failed") { + message.error("批量下载失败,请重试"); + } else { + // 继续轮询 + await poll(); + } + }; + await poll(); + } catch { + message.error("发起批量下载失败"); + } finally { + setBatchDownloading(false); } - message.success(`已下载 ${successCount}/${ids.length} 个视频`); - setSelectedIds(new Set()); }; /* 批量删除 */ @@ -823,8 +958,9 @@ const ProductLibrary: React.FC = () => { buttonSize="sm" icon={} onClick={handleBatchDownload} + disabled={batchDownloading} > - 批量下载 + {batchDownloading ? "打包中..." : "批量下载"}
{ onShare={handleShare} onDelete={handleDelete} onPublish={handlePublish} + onReviewStatusChange={handleReviewStatusChange} /> ))}
diff --git a/apps/web/src/pages/products/products.css b/apps/web/src/pages/products/products.css index 4b3b15ece..6945e006e 100644 --- a/apps/web/src/pages/products/products.css +++ b/apps/web/src/pages/products/products.css @@ -222,6 +222,42 @@ backdrop-filter: blur(4px); } +/* 复核状态标签(右上角,位于已发布徽章下方) */ +.xx-product-review-tag { + position: absolute; + top: 34px; + right: 10px; + z-index: 2; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + backdrop-filter: blur(4px); + cursor: pointer; + transition: var(--transition-all); + user-select: none; +} + +.xx-product-review-tag:hover { + transform: scale(1.05); + filter: brightness(1.1); +} + +.xx-product-review-tag.review-pending { + background: rgba(156, 163, 175, 0.9); + color: var(--text-inverse); +} + +.xx-product-review-tag.review-approved { + background: rgba(16, 185, 129, 0.9); + color: var(--text-inverse); +} + +.xx-product-review-tag.review-rejected { + background: rgba(239, 68, 68, 0.9); + color: var(--text-inverse); +} + /* 缩略图区域 */ .xx-product-thumb { aspect-ratio: 9 / 16;