refactor(product-library): Phase 2 - extract ProductCard and VideoPlayer components #913
@@ -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 (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 复选框(左上角) */}
|
||||
<div
|
||||
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={handleCheckboxClick}
|
||||
title={isSelected ? "取消选择" : "选择"}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-product-info">
|
||||
<h4 className="xx-product-title" title={product.name}>
|
||||
{product.name}
|
||||
</h4>
|
||||
<div className="xx-product-meta">
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="下载"
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</button>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="分享"
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
{product.isPublished ? (
|
||||
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
|
||||
✅ 已发布
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="xx-product-action-btn primary"
|
||||
onClick={() => onPublish(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="发布"
|
||||
>
|
||||
<CloudUploadOutlined /> 发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮(不在批量模式下显示) */}
|
||||
{!batchMode && (
|
||||
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<Popconfirm
|
||||
title={`确定删除"${product.name}"?`}
|
||||
onConfirm={() => onDelete(product.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
style={{ width: "100%", color: "#dc2626" }}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 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<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-player-video-wrap">
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
/* 无视频 URL 时用渐变占位 */
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-player-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-player-info">
|
||||
<h3 className="xx-player-title">{product.name}</h3>
|
||||
<div className="xx-player-details">
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">分辨率:</span>
|
||||
<span className="xx-player-detail-value">{product.resolution}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">时长:</span>
|
||||
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">文件大小:</span>
|
||||
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">查重率:</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="xx-player-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载视频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { mapApiProduct, getNextReviewStatus } from "./utils"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { VideoPlayer } from "./components/VideoPlayer"
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
|
||||
@@ -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<ProductCardProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 复选框(左上角) */}
|
||||
<div
|
||||
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={handleCheckboxClick}
|
||||
title={isSelected ? "取消选择" : "选择"}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-product-info">
|
||||
<h4 className="xx-product-title" title={product.name}>
|
||||
{product.name}
|
||||
</h4>
|
||||
<div className="xx-product-meta">
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="下载"
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</button>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="分享"
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
{product.isPublished ? (
|
||||
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
|
||||
✅ 已发布
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="xx-product-action-btn primary"
|
||||
onClick={() => onPublish(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="发布"
|
||||
>
|
||||
<CloudUploadOutlined /> 发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮(不在批量模式下显示) */}
|
||||
{!batchMode && (
|
||||
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<Popconfirm
|
||||
title={`确定删除"${product.name}"?`}
|
||||
onConfirm={() => onDelete(product.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
style={{ width: "100%", color: "#dc2626" }}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<VideoPlayerProps> = ({
|
||||
product,
|
||||
onClose,
|
||||
onDownload,
|
||||
onShare,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-player-video-wrap">
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
/* 无视频 URL 时用渐变占位 */
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-player-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-player-info">
|
||||
<h3 className="xx-player-title">{product.name}</h3>
|
||||
<div className="xx-player-details">
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">分辨率:</span>
|
||||
<span className="xx-player-detail-value">{product.resolution}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">时长:</span>
|
||||
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">文件大小:</span>
|
||||
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">查重率:</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="xx-player-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载视频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user