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.name} - ) : ( -
- )} -
- -
- {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 ? ( -
- ) -} +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.name} + ) : ( +
+ )} +
+ +
+ {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 ? ( +
+ ) +} 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