diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx
index d8d122b1e..61fccd9c5 100755
--- a/apps/web/src/pages/products/ProductLibrary.tsx
+++ b/apps/web/src/pages/products/ProductLibrary.tsx
@@ -3,7 +3,7 @@
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
* 使用 useQuery 对接后端真实 API(api/products.ts)
*/
-import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"
+import React, { useMemo, useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import { message, Popconfirm } from "antd"
@@ -11,14 +11,9 @@ import {
SearchOutlined,
VideoCameraOutlined,
DownloadOutlined,
- ShareAltOutlined,
DeleteOutlined,
- PlayCircleOutlined,
- PauseCircleOutlined,
- CloseOutlined,
CheckOutlined,
CloudUploadOutlined,
- EyeOutlined,
} from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import {
@@ -33,373 +28,9 @@ import {
} from "@/api/products"
import "./products.css"
import type { ProductItem } from "./types"
-import { statusConfig, reviewStatusConfig } from "./constants"
-import { formatTime, formatSize, mapApiProduct, getNextReviewStatus } from "./utils"
-
-/* ============================================================
- * 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
- onReviewStatusChange: (id: string) => void
-}> = ({
- product,
- isSelected,
- batchMode,
- onToggleSelect,
- onPlay,
- onDownload,
- onShare,
- onDelete,
- onPublish,
- onReviewStatusChange,
-}) => {
- const st = statusConfig[product.status]
-
- /** 查重率样式 */
- const dupClass =
- product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
-
- /** 点击卡片 */
- const handleCardClick = () => {
- if (batchMode) {
- onToggleSelect(product.id)
- } else {
- onPlay(product)
- }
- }
-
- /** 点击复选框 */
- const handleCheckboxClick = (e: React.MouseEvent) => {
- e.stopPropagation()
- onToggleSelect(product.id)
- }
-
- return (
-
- {/* 复选框(左上角) */}
-
- {isSelected && }
-
-
- {/* 已发布徽章(右上角) */}
- {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 ? (
-

- ) : (
-
- )}
-
- {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
- onViewDetail: (product: ProductItem) => void
-}> = ({ product, onClose, onDownload, onShare, onViewDetail }) => {
- const videoRef = useRef(null)
- const progressRef = useRef(null)
- const [isPlaying, setIsPlaying] = useState(false)
- const [currentTime, setCurrentTime] = useState(0)
- const [duration, setDuration] = useState(product.duration)
-
- const hasVideo = !!product.videoUrl
-
- /** 播放/暂停 */
- const handlePlayPause = useCallback(() => {
- const video = videoRef.current
- if (!video) return
- if (isPlaying) {
- video.pause()
- } else {
- video.play().catch(() => {})
- }
- setIsPlaying(!isPlaying)
- }, [isPlaying])
-
- /** 视频事件监听 */
- useEffect(() => {
- const video = videoRef.current
- if (!video) return
- const onTime = () => setCurrentTime(video.currentTime)
- const onDur = () => setDuration(video.duration || product.duration)
- const onEnd = () => setIsPlaying(false)
- video.addEventListener("timeupdate", onTime)
- video.addEventListener("loadedmetadata", onDur)
- video.addEventListener("ended", onEnd)
- return () => {
- video.removeEventListener("timeupdate", onTime)
- video.removeEventListener("loadedmetadata", onDur)
- video.removeEventListener("ended", onEnd)
- }
- }, [product.duration])
-
- /** 进度条点击 */
- const handleProgressClick = (e: React.MouseEvent) => {
- if (!progressRef.current) return
- const rect = progressRef.current.getBoundingClientRect()
- const percent = (e.clientX - rect.left) / rect.width
- const newTime = percent * duration
- setCurrentTime(newTime)
- if (videoRef.current) videoRef.current.currentTime = newTime
- }
-
- /** 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()}>
- {/* 视频区域 */}
-
- {hasVideo ? (
-
- ) : (
- /* 无视频 URL 时用渐变占位 */
-
-
-
- )}
-
- {/* 播放/暂停按钮 */}
-
-
- {/* 关闭按钮 */}
-
-
- {/* 进度条 */}
-
-
-
- {formatTime(currentTime)}
- {formatTime(duration)}
-
-
-
-
- {/* 信息区 */}
-
-
{product.name}
-
-
- 分辨率:
- {product.resolution}
-
-
- 时长:
- {formatTime(product.duration)}
-
-
- 文件大小:
- {formatSize(product.fileSize)}
-
-
- 查重率:
-
- {product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
-
-
-
-
-
- {/* 底部操作 */}
-
-
}
- onClick={() => onDownload(product)}
- disabled={product.status !== "completed"}
- >
- 下载视频
-
-
}
- onClick={() => onShare(product)}
- disabled={product.status !== "completed"}
- >
- 分享
-
-
}
- onClick={() => onViewDetail(product)}
- >
- 查看详情
-
-
-
-
-
-
- )
-}
+import { mapApiProduct, getNextReviewStatus } from "./utils"
+import { ProductCard } from "./components/ProductCard"
+import { VideoPlayer } from "./components/VideoPlayer"
/* ============================================================
* 主组件
diff --git a/apps/web/src/pages/products/components/ProductCard.tsx b/apps/web/src/pages/products/components/ProductCard.tsx
new file mode 100644
index 000000000..d17413d05
--- /dev/null
+++ b/apps/web/src/pages/products/components/ProductCard.tsx
@@ -0,0 +1,196 @@
+import React from "react"
+import { Popconfirm } from "antd"
+import {
+ CheckOutlined,
+ PlayCircleOutlined,
+ DownloadOutlined,
+ ShareAltOutlined,
+ DeleteOutlined,
+ CloudUploadOutlined,
+} from "@ant-design/icons"
+import type { ProductItem } from "../types"
+import { statusConfig, reviewStatusConfig } from "../constants"
+import { formatTime } from "../utils"
+
+interface ProductCardProps {
+ 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
+ onReviewStatusChange: (id: string) => void
+}
+
+export const ProductCard: React.FC = ({
+ product,
+ isSelected,
+ batchMode,
+ onToggleSelect,
+ onPlay,
+ onDownload,
+ onShare,
+ onDelete,
+ onPublish,
+ onReviewStatusChange,
+}) => {
+ const st = statusConfig[product.status]
+
+ /** 查重率样式 */
+ const dupClass =
+ product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
+
+ /** 点击卡片 */
+ const handleCardClick = () => {
+ if (batchMode) {
+ onToggleSelect(product.id)
+ } else {
+ onPlay(product)
+ }
+ }
+
+ /** 点击复选框 */
+ const handleCheckboxClick = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onToggleSelect(product.id)
+ }
+
+ return (
+
+ {/* 复选框(左上角) */}
+
+ {isSelected && }
+
+
+ {/* 已发布徽章(右上角) */}
+ {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 ? (
+

+ ) : (
+
+ )}
+
+ {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="取消"
+ >
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/pages/products/components/VideoPlayer.tsx b/apps/web/src/pages/products/components/VideoPlayer.tsx
new file mode 100644
index 000000000..5e5ac79d3
--- /dev/null
+++ b/apps/web/src/pages/products/components/VideoPlayer.tsx
@@ -0,0 +1,199 @@
+import React, { useRef, useState, useEffect, useCallback } from "react"
+import {
+ VideoCameraOutlined,
+ PlayCircleOutlined,
+ PauseCircleOutlined,
+ CloseOutlined,
+ DownloadOutlined,
+ ShareAltOutlined,
+ EyeOutlined,
+} from "@ant-design/icons"
+import { Button } from "@/components/ui"
+import type { ProductItem } from "../types"
+import { formatTime, formatSize } from "../utils"
+
+interface VideoPlayerProps {
+ product: ProductItem
+ onClose: () => void
+ onDownload: (product: ProductItem) => void
+ onShare: (product: ProductItem) => void
+ onViewDetail: (product: ProductItem) => void
+}
+
+export const VideoPlayer: React.FC = ({
+ product,
+ onClose,
+ onDownload,
+ onShare,
+ onViewDetail,
+}) => {
+ const videoRef = useRef(null)
+ const progressRef = useRef(null)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(product.duration)
+
+ const hasVideo = !!product.videoUrl
+
+ /** 播放/暂停 */
+ const handlePlayPause = useCallback(() => {
+ const video = videoRef.current
+ if (!video) return
+ if (isPlaying) {
+ video.pause()
+ } else {
+ video.play().catch(() => {})
+ }
+ setIsPlaying(!isPlaying)
+ }, [isPlaying])
+
+ /** 视频事件监听 */
+ useEffect(() => {
+ const video = videoRef.current
+ if (!video) return
+ const onTime = () => setCurrentTime(video.currentTime)
+ const onDur = () => setDuration(video.duration || product.duration)
+ const onEnd = () => setIsPlaying(false)
+ video.addEventListener("timeupdate", onTime)
+ video.addEventListener("loadedmetadata", onDur)
+ video.addEventListener("ended", onEnd)
+ return () => {
+ video.removeEventListener("timeupdate", onTime)
+ video.removeEventListener("loadedmetadata", onDur)
+ video.removeEventListener("ended", onEnd)
+ }
+ }, [product.duration])
+
+ /** 进度条点击 */
+ const handleProgressClick = (e: React.MouseEvent) => {
+ if (!progressRef.current) return
+ const rect = progressRef.current.getBoundingClientRect()
+ const percent = (e.clientX - rect.left) / rect.width
+ const newTime = percent * duration
+ setCurrentTime(newTime)
+ if (videoRef.current) videoRef.current.currentTime = newTime
+ }
+
+ /** 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()}>
+ {/* 视频区域 */}
+
+ {hasVideo ? (
+
+ ) : (
+ /* 无视频 URL 时用渐变占位 */
+
+
+
+ )}
+
+ {/* 播放/暂停按钮 */}
+
+
+ {/* 关闭按钮 */}
+
+
+ {/* 进度条 */}
+
+
+
+ {formatTime(currentTime)}
+ {formatTime(duration)}
+
+
+
+
+ {/* 信息区 */}
+
+
{product.name}
+
+
+ 分辨率:
+ {product.resolution}
+
+
+ 时长:
+ {formatTime(product.duration)}
+
+
+ 文件大小:
+ {formatSize(product.fileSize)}
+
+
+ 查重率:
+
+ {product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
+
+
+
+
+
+ {/* 底部操作 */}
+
+
}
+ onClick={() => onDownload(product)}
+ disabled={product.status !== "completed"}
+ >
+ 下载视频
+
+
}
+ onClick={() => onShare(product)}
+ disabled={product.status !== "completed"}
+ >
+ 分享
+
+
}
+ onClick={() => onViewDetail(product)}
+ >
+ 查看详情
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/test/pages/products/smoke.test.tsx b/apps/web/src/test/pages/products/smoke.test.tsx
index f5f347a8d..324c7a6e8 100644
--- a/apps/web/src/test/pages/products/smoke.test.tsx
+++ b/apps/web/src/test/pages/products/smoke.test.tsx
@@ -8,13 +8,17 @@ import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/products/ProductLibrary"
-// 类型与常量(Phase 1)
+// 类型与常量
import "@/pages/products/types"
import "@/pages/products/constants"
-// 工具函数(Phase 1)
+// 工具函数
import "@/pages/products/utils/index"
+// 子组件
+import "@/pages/products/components/ProductCard"
+import "@/pages/products/components/VideoPlayer"
+
describe("ProductLibrary module smoke test", () => {
it("should load all product modules", () => {
// 纯模块加载测试,确保所有组件/工具函数能正常 import