From dc2555bae4f0ccdd3a2c9cd2bc27a9bb07db60cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Wed, 1 Jul 2026 00:36:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=94=B9=E9=80=A0=E6=88=90?= =?UTF-8?q?=E7=89=87=E5=BA=93=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 ProductLibrary.tsx 为 V21 设计系统风格 - 卡片网格布局(4列响应式),支持视频缩略图展示 - 视频播放弹窗(mock),含播放/暂停、进度条、ESC关闭 - 批量操作:全选、批量发布/下载/删除 - 筛选:状态、时间、时长、搜索 - 已发布徽章(右上角绿色)、选中高亮 - 查重率显示(good/warn/bad 三色) - 新建 products.css,完整 V21 样式 + 深色模式支持 - 使用 mock 数据,后端 API 对接暂不要求 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../web/src/pages/products/ProductLibrary.tsx | 948 +++++++++++++----- apps/web/src/pages/products/products.css | 699 +++++++++++++ 2 files changed, 1381 insertions(+), 266 deletions(-) create mode 100644 apps/web/src/pages/products/products.css diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx index c75753264..30826e4a0 100644 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -1,289 +1,705 @@ /** - * 成品库页面 - * 展示用户生成的成品视频,支持查重率显示 + * 成片库页面 — V21 设计系统 + * 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选 + * 使用 mock 数据,后端 API 对接暂不要求 */ -import React, { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"; +import { message, Popconfirm } from "antd"; import { - Card, - Button, - Typography, - Space, - Row, - Col, - Tag, - Empty, - Spin, - Modal, - Popconfirm, - message, - Progress, - Image, -} from "antd"; -import { - DeleteOutlined, + SearchOutlined, + VideoCameraOutlined, DownloadOutlined, + ShareAltOutlined, + DeleteOutlined, PlayCircleOutlined, - EyeOutlined, + PauseCircleOutlined, + CloseOutlined, + CheckOutlined, + CloudUploadOutlined, } from "@ant-design/icons"; -import { - getProducts, - deleteProduct, - getProductDownloadUrl, - type ProductItem, -} from "@/api/products"; -import PageHead from "@/components/layout/PageHead"; +import { Button, Input, Select } from "@/components/ui"; +import "./products.css"; -const { Text } = Typography; +/* ============================================================ + * 类型 + * ============================================================ */ +type ProductStatus = "completed" | "processing" | "review" | "failed"; -/** 状态标签 */ -const StatusTag: React.FC<{ status: string }> = ({ status }) => { - const config: Record = { - completed: { color: "success", text: "已完成" }, - processing: { color: "processing", text: "处理中" }, - failed: { color: "error", text: "失败" }, - }; - const c = config[status] || config.processing; - return {c.text}; +interface ProductItem { + id: string; + name: string; + status: ProductStatus; + duration: number; // 秒 + date: string; + gradient: string; + duplicateRate: number; // 查重率百分比 + isPublished: boolean; + resolution: string; + fileSize: number; // MB +} + +/* ============================================================ + * Mock 数据 + * ============================================================ */ +const GRADIENTS = [ + "linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)", + "linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)", + "linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)", + "linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)", + "linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)", + "linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)", + "linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)", + "linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)", + "linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)", + "linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)", + "linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)", + "linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)", +]; + +const MOCK_PRODUCTS: ProductItem[] = [ + { id: "p-1", name: "产品介绍_智能客服系统_v2", status: "completed", duration: 95, date: "2026-06-28", gradient: GRADIENTS[0], duplicateRate: 3.2, isPublished: true, resolution: "1080×1920", fileSize: 45.6 }, + { id: "p-2", name: "品牌宣传_春季活动", status: "completed", duration: 60, date: "2026-06-27", gradient: GRADIENTS[1], duplicateRate: 5.8, isPublished: true, resolution: "1080×1920", fileSize: 32.1 }, + { id: "p-3", name: "教程_如何使用AI生成视频", status: "completed", duration: 180, date: "2026-06-26", gradient: GRADIENTS[2], duplicateRate: 1.5, isPublished: false, resolution: "1080×1920", fileSize: 78.3 }, + { id: "p-4", name: "电商推广_夏日特惠", status: "processing", duration: 45, date: "2026-06-25", gradient: GRADIENTS[3], duplicateRate: 0, isPublished: false, resolution: "1080×1920", fileSize: 0 }, + { id: "p-5", name: "知识科普_量子计算入门", status: "completed", duration: 120, date: "2026-06-24", gradient: GRADIENTS[4], duplicateRate: 8.1, isPublished: false, resolution: "1080×1920", fileSize: 56.2 }, + { id: "p-6", name: "企业宣传片_科技创新", status: "review", duration: 150, date: "2026-06-23", gradient: GRADIENTS[5], duplicateRate: 12.5, isPublished: false, resolution: "1080×1920", fileSize: 68.9 }, + { id: "p-7", name: "短视频_产品开箱测评", status: "completed", duration: 75, date: "2026-06-22", gradient: GRADIENTS[6], duplicateRate: 6.3, isPublished: true, resolution: "1080×1920", fileSize: 38.7 }, + { id: "p-8", name: "Vlog_日常工作记录", status: "completed", duration: 200, date: "2026-06-21", gradient: GRADIENTS[7], duplicateRate: 2.1, isPublished: false, resolution: "1080×1920", fileSize: 92.4 }, + { id: "p-9", name: "广告_新品发布会预告", status: "failed", duration: 30, date: "2026-06-20", gradient: GRADIENTS[8], duplicateRate: 0, isPublished: false, resolution: "1080×1920", fileSize: 0 }, + { id: "p-10", name: "教育课程_AI基础讲解", status: "completed", duration: 300, date: "2026-06-19", gradient: GRADIENTS[9], duplicateRate: 4.7, isPublished: false, resolution: "1080×1920", fileSize: 125.8 }, + { id: "p-11", name: "直播带货_美妆专场", status: "review", duration: 90, date: "2026-06-18", gradient: GRADIENTS[10], duplicateRate: 15.3, isPublished: false, resolution: "1080×1920", fileSize: 42.1 }, + { id: "p-12", name: "品牌故事_创始人访谈", status: "completed", duration: 240, date: "2026-06-17", gradient: GRADIENTS[11], duplicateRate: 7.9, isPublished: false, resolution: "1080×1920", fileSize: 108.5 }, +]; + +/* ============================================================ + * 工具函数 + * ============================================================ */ +const statusConfig: Record = { + completed: { text: "已完成", className: "completed" }, + processing: { text: "处理中", className: "processing" }, + review: { text: "待复核", className: "review" }, + failed: { text: "失败", className: "failed" }, }; -const ProductLibrary: React.FC = () => { - const queryClient = useQueryClient(); - const [previewProduct, setPreviewProduct] = useState( - null, - ); +/** 格式化时间 mm:ss */ +const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; +}; - // 获取成品列表 - const { data: products = [], isLoading } = useQuery({ - queryKey: ["products"], - queryFn: getProducts, - }); +/** 格式化文件大小 */ +const formatSize = (mb: number): string => { + if (mb <= 0) return "-"; + if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`; + return `${mb.toFixed(1)} MB`; +}; - // 删除成品 - const deleteMutation = useMutation({ - mutationFn: deleteProduct, - onSuccess: () => { - message.success("已删除"); - queryClient.invalidateQueries({ queryKey: ["products"] }); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("删除失败"); - }, - }); +/* ============================================================ + * ProductCard 组件 + * ============================================================ */ +const ProductCard: React.FC<{ + product: ProductItem; + isSelected: boolean; + batchMode: boolean; + onToggleSelect: (id: string) => void; + onPlay: (product: ProductItem) => void; + onDownload: (product: ProductItem) => void; + onShare: (product: ProductItem) => void; + onDelete: (id: string) => void; + onPublish: (product: ProductItem) => void; +}> = ({ product, isSelected, batchMode, onToggleSelect, onPlay, onDownload, onShare, onDelete, onPublish }) => { + const st = statusConfig[product.status]; - // 下载 - const handleDownload = async (productId: string) => { - try { - const { url } = await getProductDownloadUrl(productId); - window.open(url, "_blank"); - } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("获取下载链接失败"); + /** 查重率样式 */ + const dupClass = product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"; + + /** 点击卡片 */ + const handleCardClick = () => { + if (batchMode) { + onToggleSelect(product.id); + } else { + onPlay(product); } }; - return ( -
- + /** 点击复选框 */ + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleSelect(product.id); + }; - {isLoading ? ( -
- -
- ) : products.length === 0 ? ( - - - - ) : ( - - {products.map((product) => ( - - - ) : ( -
- -
- ) - } - actions={[ - - - - } - > - {previewProduct && ( - - {previewProduct.video_url ? ( - + {/* 缩略图 */} +
+
+
+ +
+ {product.duration > 0 && ( + {formatTime(product.duration)} )} - +
+ + {/* 信息区 */} +
+

+ {product.name} +

+
+ {st.text} + {product.date} +
+ {product.duplicateRate > 0 && ( + + 查重率:{product.duplicateRate.toFixed(1)}% + + )} +
+ + {/* 操作按钮 */} +
e.stopPropagation()}> + + + {product.isPublished ? ( + + ✅ 已发布 + + ) : ( + + )} +
+ + {/* 删除按钮(不在批量模式下显示) */} + {!batchMode && ( +
e.stopPropagation()}> + onDelete(product.id)} + okText="删除" + cancelText="取消" + > + + +
+ )} +
+ ); +}; + +/* ============================================================ + * VideoPlayer 弹窗组件 + * ============================================================ */ +const VideoPlayer: React.FC<{ + product: ProductItem; + onClose: () => void; + onDownload: (product: ProductItem) => void; + onShare: (product: ProductItem) => void; +}> = ({ product, onClose, onDownload, onShare }) => { + const progressRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration] = useState(product.duration); + const intervalRef = useRef(null); + + /** Mock 播放控制 */ + const handlePlayPause = useCallback(() => { + if (isPlaying) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsPlaying(false); + } else { + setIsPlaying(true); + intervalRef.current = window.setInterval(() => { + setCurrentTime((prev) => { + if (prev >= duration) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsPlaying(false); + return 0; + } + return prev + 0.1; + }); + }, 100); + } + }, [isPlaying, duration]); + + /** 进度条点击 */ + const handleProgressClick = (e: React.MouseEvent) => { + if (!progressRef.current) return; + const rect = progressRef.current.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + setCurrentTime(percent * duration); + }; + + /** 关闭时清理 */ + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, []); + + /** ESC 关闭 */ + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose]); + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + return ( +
+
e.stopPropagation()}> + {/* 视频区域 */} +
+ {/* Mock 视频 — 用渐变背景代替 */} +
+ +
+ + {/* 播放/暂停按钮 */} + + + {/* 关闭按钮 */} + + + {/* 进度条 */} +
+
+
+
+
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+
+ + {/* 信息区 */} +
+

{product.name}

+
+
+ 分辨率: + {product.resolution} +
+
+ 时长: + {formatTime(product.duration)} +
+
+ 文件大小: + {formatSize(product.fileSize)} +
+
+ 查重率: + + {product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"} + +
+
+
+ + {/* 底部操作 */} +
+ + +
+ +
+
+
+ ); +}; + +/* ============================================================ + * 主组件 + * ============================================================ */ +const ProductLibrary: React.FC = () => { + /* 数据 */ + const [products, setProducts] = useState(MOCK_PRODUCTS); + + /* 筛选 */ + const [searchText, setSearchText] = useState(""); + const [filterStatus, setFilterStatus] = useState("all"); + const [filterTime, setFilterTime] = useState("all"); + const [filterDuration, setFilterDuration] = useState("all"); + + /* 批量操作 */ + const [selectedIds, setSelectedIds] = useState>(new Set()); + + /* 播放器 */ + const [playingProduct, setPlayingProduct] = useState(null); + + /* 派生数据 */ + const batchMode = selectedIds.size > 0; + + const filteredProducts = useMemo(() => { + let list = products; + + /* 状态筛选 */ + if (filterStatus !== "all") { + list = list.filter((p) => p.status === filterStatus); + } + + /* 时间筛选 */ + if (filterTime !== "all") { + const now = new Date("2026-07-01"); + list = list.filter((p) => { + const d = new Date(p.date); + const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); + switch (filterTime) { + case "today": return diff < 1; + case "week": return diff <= 7; + case "month": return diff <= 30; + default: return true; + } + }); + } + + /* 时长筛选 */ + if (filterDuration !== "all") { + list = list.filter((p) => { + switch (filterDuration) { + case "short": return p.duration > 0 && p.duration <= 60; + case "medium": return p.duration > 60 && p.duration <= 180; + case "long": return p.duration > 180; + default: return true; + } + }); + } + + /* 搜索 */ + if (searchText.trim()) { + const q = searchText.trim().toLowerCase(); + list = list.filter((p) => p.name.toLowerCase().includes(q)); + } + + return list; + }, [products, filterStatus, filterTime, filterDuration, searchText]); + + /* 全选 */ + const allSelected = filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id)); + + const handleSelectAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(filteredProducts.map((p) => p.id))); + } + }; + + /* 切换单个选择 */ + const handleToggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + /* 下载 */ + const handleDownload = (product: ProductItem) => { + if (product.status !== "completed") return; + message.success(`正在下载"${product.name}"(mock)`); + }; + + /* 分享 */ + const handleShare = (product: ProductItem) => { + if (product.status !== "completed") return; + const link = `https://xiaoxia.ai/share/${product.id}`; + navigator.clipboard?.writeText(link).then( + () => message.success(`分享链接已复制:${link}`), + () => message.success(`分享链接:${link}(请手动复制)`) + ); + }; + + /* 删除 */ + const handleDelete = (id: string) => { + setProducts((prev) => prev.filter((p) => p.id !== id)); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + message.success("已删除"); + }; + + /* 发布 */ + const handlePublish = (product: ProductItem) => { + setProducts((prev) => + prev.map((p) => (p.id === product.id ? { ...p, isPublished: true } : p)) + ); + message.success(`"${product.name}" 已发布`); + }; + + /* 批量下载 */ + const handleBatchDownload = () => { + const count = selectedIds.size; + message.success(`正在批量下载 ${count} 个视频(mock)`); + setSelectedIds(new Set()); + }; + + /* 批量删除 */ + const handleBatchDelete = () => { + const count = selectedIds.size; + setProducts((prev) => prev.filter((p) => !selectedIds.has(p.id))); + setSelectedIds(new Set()); + message.success(`已批量删除 ${count} 个视频`); + }; + + /* 批量发布 */ + const handleBatchPublish = () => { + const ids = Array.from(selectedIds); + setProducts((prev) => + prev.map((p) => (ids.includes(p.id) ? { ...p, isPublished: true } : p)) + ); + message.success(`已批量发布 ${ids.length} 个视频`); + setSelectedIds(new Set()); + }; + + return ( +
+ {/* 页面头部 */} +
+

成片库

+
+ +
+
+ + {/* 批量操作栏 */} + {batchMode && ( +
+
+
+ {allSelected && } +
+ + {allSelected ? "取消全选" : "全选"} + + + 已选择 {selectedIds.size} 项 + +
+
+ + + + + + +
+
+ )} + + {/* 筛选栏 */} +
+
+ } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 220 }} + /> + +