Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a16ba5f93e | |||
| 2248e9e377 | |||
| bb9abfa470 | |||
| fe1bcf67df | |||
| 078c56ef62 | |||
| eb999016c8 |
Regular → Executable
+29
-399
@@ -3,252 +3,28 @@
|
||||
* 路由:/app/products/:id
|
||||
* 展示视频播放器 + 完整元数据 + 下载/分享/删除操作
|
||||
*/
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react"
|
||||
import { useParams, useNavigate } from "react-router-dom"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleFilled,
|
||||
PauseCircleFilled,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
ExpandOutlined,
|
||||
LoadingOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import {
|
||||
getProduct,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
type ProductItem,
|
||||
} from "../../api/products"
|
||||
import React from "react"
|
||||
import { LoadingOutlined, WarningOutlined, ArrowLeftOutlined } from "@ant-design/icons"
|
||||
import { Button } from "../../components/ui"
|
||||
import { DetailHeader } from "./components/DetailHeader"
|
||||
import { DetailVideoPlayer } from "./components/DetailVideoPlayer"
|
||||
import { ProductInfoPanel } from "./components/ProductInfoPanel"
|
||||
import { useProductDetail } from "./hooks/useProductDetail"
|
||||
import "./products.css"
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
|
||||
/** 格式化时长(秒 → "MM:SS") */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小(MB) */
|
||||
const formatFileSize = (mb: number): string => {
|
||||
if (!mb || mb <= 0) return "-"
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return "-"
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/** 状态标签 */
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
completed: { label: "已完成", color: "#10b981" },
|
||||
processing: { label: "处理中", color: "#6366f1" },
|
||||
pending: { label: "待处理", color: "#f59e0b" },
|
||||
failed: { label: "失败", color: "#ef4444" },
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const ProductDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 获取产品详情 ── */
|
||||
const {
|
||||
data: product,
|
||||
product,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<ProductItem, Error>({
|
||||
queryKey: ["product", id],
|
||||
queryFn: () => getProduct(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteProduct(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
navigate("/app/products")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 视频播放器状态 ── */
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffered, setBuffered] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 自动隐藏控制条 ── */
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setShowControls(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
if (isPlaying) {
|
||||
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000)
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
/* ── 播放控制 ── */
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.paused) {
|
||||
v.play().catch(() => {})
|
||||
} else {
|
||||
v.pause()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const v = videoRef.current
|
||||
const bar = progressRef.current
|
||||
if (!v || !bar || !duration) return
|
||||
const rect = bar.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
v.currentTime = ratio * duration
|
||||
},
|
||||
[duration],
|
||||
)
|
||||
|
||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = videoRef.current
|
||||
const val = parseFloat(e.target.value)
|
||||
if (v) v.volume = val
|
||||
setVolume(val)
|
||||
setIsMuted(val === 0)
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (isMuted) {
|
||||
v.muted = false
|
||||
v.volume = volume || 1
|
||||
setIsMuted(false)
|
||||
} else {
|
||||
v.muted = true
|
||||
setIsMuted(true)
|
||||
}
|
||||
}, [isMuted, volume])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen?.().catch(() => {})
|
||||
} else {
|
||||
document.exitFullscreen?.().catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 视频事件监听 ── */
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
|
||||
const onPlay = () => setIsPlaying(true)
|
||||
const onPause = () => setIsPlaying(false)
|
||||
const onTimeUpdate = () => setCurrentTime(v.currentTime)
|
||||
const onLoadedMetadata = () => setDuration(v.duration)
|
||||
const onProgress = () => {
|
||||
if (v.buffered.length > 0) {
|
||||
setBuffered(v.buffered.end(v.buffered.length - 1))
|
||||
}
|
||||
}
|
||||
const onEnded = () => setIsPlaying(false)
|
||||
const onFSChange = () => setIsFullscreen(!!document.fullscreenElement)
|
||||
|
||||
v.addEventListener("play", onPlay)
|
||||
v.addEventListener("pause", onPause)
|
||||
v.addEventListener("timeupdate", onTimeUpdate)
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata)
|
||||
v.addEventListener("progress", onProgress)
|
||||
v.addEventListener("ended", onEnded)
|
||||
document.addEventListener("fullscreenchange", onFSChange)
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay)
|
||||
v.removeEventListener("pause", onPause)
|
||||
v.removeEventListener("timeupdate", onTimeUpdate)
|
||||
v.removeEventListener("loadedmetadata", onLoadedMetadata)
|
||||
v.removeEventListener("progress", onProgress)
|
||||
v.removeEventListener("ended", onEnded)
|
||||
document.removeEventListener("fullscreenchange", onFSChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 播放时自动隐藏/显示控制条 */
|
||||
useEffect(() => {
|
||||
resetHideTimer()
|
||||
return () => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
}, [isPlaying, resetHideTimer])
|
||||
|
||||
/* ── 下载 ── */
|
||||
const handleDownload = async () => {
|
||||
if (!product || product.status !== "completed") return
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = ""
|
||||
a.click()
|
||||
} catch {
|
||||
// message.error handled by caller
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 分享 ── */
|
||||
const handleShare = () => {
|
||||
if (!product) return
|
||||
const link = `${window.location.origin}/app/products/${product.id}`
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => {},
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 删除 ── */
|
||||
const handleDelete = () => {
|
||||
if (!id) return
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
goBack,
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleDelete,
|
||||
deleteLoading,
|
||||
canDownload,
|
||||
} = useProductDetail()
|
||||
|
||||
/* ── 加载状态 ── */
|
||||
if (isLoading) {
|
||||
@@ -270,7 +46,7 @@ const ProductDetail: React.FC = () => {
|
||||
<WarningOutlined style={{ fontSize: 48, color: "var(--error-color)" }} />
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "无法获取产品信息"}</p>
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={() => navigate("/app/products")}>
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={goBack}>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</Button>
|
||||
</div>
|
||||
@@ -278,170 +54,24 @@ const ProductDetail: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const statusInfo = STATUS_MAP[product.status] || {
|
||||
label: product.status,
|
||||
color: "#94a3b8",
|
||||
}
|
||||
const progress = Math.round((currentTime / (duration || 1)) * 100)
|
||||
const bufferedPct = Math.round((buffered / (duration || 1)) * 100)
|
||||
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
{/* ── 顶部导航 ── */}
|
||||
<div className="xx-detail-header">
|
||||
<button className="xx-detail-back-btn" onClick={() => navigate("/app/products")}>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</button>
|
||||
<div className="xx-detail-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={handleShare}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDelete}
|
||||
loading={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DetailHeader
|
||||
onBack={goBack}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
canDownload={canDownload}
|
||||
deleteLoading={deleteLoading}
|
||||
/>
|
||||
|
||||
{/* ── 主体内容 ── */}
|
||||
<div className="xx-detail-body">
|
||||
{/* 视频播放器 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`xx-detail-player ${isFullscreen ? "is-fullscreen" : ""}`}
|
||||
onMouseMove={resetHideTimer}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{product.video_url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.video_url}
|
||||
poster={product.thumbnail_url || undefined}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-detail-player-empty">
|
||||
{product.thumbnail_url ? (
|
||||
<img src={product.thumbnail_url} alt={product.title} />
|
||||
) : (
|
||||
<div className="xx-detail-player-placeholder">
|
||||
<PlayCircleFilled style={{ fontSize: 64, color: "var(--text-tertiary)" }} />
|
||||
<p>视频暂不可用</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className={`xx-detail-player-controls ${showControls || !isPlaying ? "visible" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
<div className="xx-dp-progress" ref={progressRef} onClick={handleSeek}>
|
||||
<div className="xx-dp-progress-track">
|
||||
<div className="xx-dp-progress-buffered" style={{ width: `${bufferedPct}%` }} />
|
||||
<div className="xx-dp-progress-played" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-dp-bar">
|
||||
{/* 左:播放/暂停 */}
|
||||
<button className="xx-dp-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
||||
</button>
|
||||
|
||||
{/* 时间 */}
|
||||
<span className="xx-dp-time">
|
||||
{formatDuration(currentTime)} / {formatDuration(duration)}
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="xx-dp-volume">
|
||||
<button className="xx-dp-btn" onClick={toggleMute}>
|
||||
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="xx-dp-volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 全屏 */}
|
||||
<button className="xx-dp-btn" onClick={toggleFullscreen}>
|
||||
<ExpandOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品信息 */}
|
||||
<div className="xx-detail-info">
|
||||
<h1 className="xx-detail-title">{product.title}</h1>
|
||||
|
||||
<div className="xx-detail-meta-grid">
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">状态</span>
|
||||
<span className="xx-detail-meta-value" style={{ color: statusInfo.color }}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">时长</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatDuration(product.duration_seconds ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">分辨率</span>
|
||||
<span className="xx-detail-meta-value">{product.resolution || "-"}</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">文件大小</span>
|
||||
<span className="xx-detail-meta-value">{formatFileSize(product.file_size ?? 0)}</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">查重率</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{(product.duplicate_rate ?? 0) > 0
|
||||
? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">创建时间</span>
|
||||
<span className="xx-detail-meta-value">{formatDate(product.created_at ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DetailVideoPlayer
|
||||
videoUrl={product.video_url || undefined}
|
||||
posterUrl={product.thumbnail_url || undefined}
|
||||
title={product.title}
|
||||
/>
|
||||
<ProductInfoPanel product={product} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react"
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "../../../components/ui"
|
||||
|
||||
interface DetailHeaderProps {
|
||||
onBack: () => void
|
||||
onDownload: () => void
|
||||
onShare: () => void
|
||||
onDelete: () => void
|
||||
canDownload: boolean
|
||||
deleteLoading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品详情页顶部导航栏
|
||||
*/
|
||||
export const DetailHeader: React.FC<DetailHeaderProps> = ({
|
||||
onBack,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
canDownload,
|
||||
deleteLoading,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-detail-header">
|
||||
<button className="xx-detail-back-btn" onClick={onBack}>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</button>
|
||||
<div className="xx-detail-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onDownload}
|
||||
disabled={!canDownload}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={onShare}
|
||||
disabled={!canDownload}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onDelete}
|
||||
loading={deleteLoading}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleFilled,
|
||||
PauseCircleFilled,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
ExpandOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { formatDuration } from "../detailUtils"
|
||||
import { useVideoPlayer } from "../hooks/useVideoPlayer"
|
||||
|
||||
interface DetailVideoPlayerProps {
|
||||
videoUrl?: string
|
||||
posterUrl?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品详情页视频播放器
|
||||
* 含自定义控制条:播放/暂停、进度、音量、全屏
|
||||
*/
|
||||
export const DetailVideoPlayer: React.FC<DetailVideoPlayerProps> = ({
|
||||
videoUrl,
|
||||
posterUrl,
|
||||
title,
|
||||
}) => {
|
||||
const {
|
||||
videoRef,
|
||||
progressRef,
|
||||
containerRef,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
showControls,
|
||||
isFullscreen,
|
||||
progress,
|
||||
bufferedPct,
|
||||
togglePlay,
|
||||
handleSeek,
|
||||
handleVolumeChange,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
resetHideTimer,
|
||||
} = useVideoPlayer()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`xx-detail-player ${isFullscreen ? "is-fullscreen" : ""}`}
|
||||
onMouseMove={resetHideTimer}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{videoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
poster={posterUrl || undefined}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-detail-player-empty">
|
||||
{posterUrl ? (
|
||||
<img src={posterUrl} alt={title} />
|
||||
) : (
|
||||
<div className="xx-detail-player-placeholder">
|
||||
<PlayCircleFilled style={{ fontSize: 64, color: "var(--text-tertiary)" }} />
|
||||
<p>视频暂不可用</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className={`xx-detail-player-controls ${showControls || !isPlaying ? "visible" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
<div className="xx-dp-progress" ref={progressRef} onClick={handleSeek}>
|
||||
<div className="xx-dp-progress-track">
|
||||
<div className="xx-dp-progress-buffered" style={{ width: `${bufferedPct}%` }} />
|
||||
<div className="xx-dp-progress-played" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-dp-bar">
|
||||
{/* 左:播放/暂停 */}
|
||||
<button className="xx-dp-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
||||
</button>
|
||||
|
||||
{/* 时间 */}
|
||||
<span className="xx-dp-time">
|
||||
{formatDuration(currentTime)} / {formatDuration(duration)}
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="xx-dp-volume">
|
||||
<button className="xx-dp-btn" onClick={toggleMute}>
|
||||
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="xx-dp-volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 全屏 */}
|
||||
<button className="xx-dp-btn" onClick={toggleFullscreen}>
|
||||
<ExpandOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
import type { ProductItem } from "../../../api/products"
|
||||
import { STATUS_MAP } from "../constants"
|
||||
import { formatDuration, formatFileSize, formatDate } from "../detailUtils"
|
||||
|
||||
interface ProductInfoPanelProps {
|
||||
product: ProductItem
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品信息面板
|
||||
* 展示标题 + 元数据网格
|
||||
*/
|
||||
export const ProductInfoPanel: React.FC<ProductInfoPanelProps> = ({ product }) => {
|
||||
const statusInfo = STATUS_MAP[product.status] || {
|
||||
label: product.status,
|
||||
color: "#94a3b8",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-detail-info">
|
||||
<h1 className="xx-detail-title">{product.title}</h1>
|
||||
|
||||
<div className="xx-detail-meta-grid">
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">状态</span>
|
||||
<span className="xx-detail-meta-value" style={{ color: statusInfo.color }}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">时长</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatDuration(product.duration_seconds ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">分辨率</span>
|
||||
<span className="xx-detail-meta-value">{product.resolution || "-"}</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">文件大小</span>
|
||||
<span className="xx-detail-meta-value">{formatFileSize(product.file_size ?? 0)}</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">查重率</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{(product.duplicate_rate ?? 0) > 0
|
||||
? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">创建时间</span>
|
||||
<span className="xx-detail-meta-value">{formatDate(product.created_at ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,3 +32,11 @@ export const reviewStatusConfig: Record<ReviewStatus, { text: string; className:
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
export const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"]
|
||||
|
||||
/** 后端原始状态 → 标签映射(用于详情页) */
|
||||
export const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
completed: { label: "已完成", color: "#10b981" },
|
||||
processing: { label: "处理中", color: "#6366f1" },
|
||||
pending: { label: "待处理", color: "#f59e0b" },
|
||||
failed: { label: "失败", color: "#ef4444" },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 格式化时长(秒 → "MM:SS") */
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小(MB) */
|
||||
export const formatFileSize = (mb: number): string => {
|
||||
if (!mb || mb <= 0) return "-"
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
export const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return "-"
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback } from "react"
|
||||
import { useNavigate, useParams } from "react-router-dom"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
getProduct,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
type ProductItem,
|
||||
} from "../../../api/products"
|
||||
|
||||
/**
|
||||
* 产品详情业务 Hook
|
||||
* 封装产品数据获取、删除、下载、分享等操作
|
||||
*/
|
||||
export const useProductDetail = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 获取产品详情 ── */
|
||||
const {
|
||||
data: product,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<ProductItem, Error>({
|
||||
queryKey: ["product", id],
|
||||
queryFn: () => getProduct(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteProduct(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
navigate("/app/products")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 返回成品库 ── */
|
||||
const goBack = useCallback(() => {
|
||||
navigate("/app/products")
|
||||
}, [navigate])
|
||||
|
||||
/* ── 下载 ── */
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!product || product.status !== "completed") return
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = ""
|
||||
a.click()
|
||||
} catch {
|
||||
// message.error handled by caller
|
||||
}
|
||||
}, [product])
|
||||
|
||||
/* ── 分享 ── */
|
||||
const handleShare = useCallback(() => {
|
||||
if (!product) return
|
||||
const link = `${window.location.origin}/app/products/${product.id}`
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => {},
|
||||
() => {},
|
||||
)
|
||||
}, [product])
|
||||
|
||||
/* ── 删除 ── */
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!id) return
|
||||
deleteMutation.mutate()
|
||||
}, [id, deleteMutation])
|
||||
|
||||
return {
|
||||
product,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
goBack,
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleDelete,
|
||||
deleteLoading: deleteMutation.isPending,
|
||||
canDownload: product?.status === "completed",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 视频播放器业务 Hook
|
||||
* 封装播放控制、进度、音量、全屏等所有播放器状态逻辑
|
||||
*/
|
||||
export const useVideoPlayer = () => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffered, setBuffered] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
/* ── 自动隐藏控制条 ── */
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setShowControls(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
if (isPlaying) {
|
||||
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000)
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
/* ── 播放控制 ── */
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.paused) {
|
||||
v.play().catch(() => {})
|
||||
} else {
|
||||
v.pause()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const v = videoRef.current
|
||||
const bar = progressRef.current
|
||||
if (!v || !bar || !duration) return
|
||||
const rect = bar.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
v.currentTime = ratio * duration
|
||||
},
|
||||
[duration],
|
||||
)
|
||||
|
||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = videoRef.current
|
||||
const val = parseFloat(e.target.value)
|
||||
if (v) v.volume = val
|
||||
setVolume(val)
|
||||
setIsMuted(val === 0)
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (isMuted) {
|
||||
v.muted = false
|
||||
v.volume = volume || 1
|
||||
setIsMuted(false)
|
||||
} else {
|
||||
v.muted = true
|
||||
setIsMuted(true)
|
||||
}
|
||||
}, [isMuted, volume])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen?.().catch(() => {})
|
||||
} else {
|
||||
document.exitFullscreen?.().catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 视频事件监听 ── */
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
|
||||
const onPlay = () => setIsPlaying(true)
|
||||
const onPause = () => setIsPlaying(false)
|
||||
const onTimeUpdate = () => setCurrentTime(v.currentTime)
|
||||
const onLoadedMetadata = () => setDuration(v.duration)
|
||||
const onProgress = () => {
|
||||
if (v.buffered.length > 0) {
|
||||
setBuffered(v.buffered.end(v.buffered.length - 1))
|
||||
}
|
||||
}
|
||||
const onEnded = () => setIsPlaying(false)
|
||||
const onFSChange = () => setIsFullscreen(!!document.fullscreenElement)
|
||||
|
||||
v.addEventListener("play", onPlay)
|
||||
v.addEventListener("pause", onPause)
|
||||
v.addEventListener("timeupdate", onTimeUpdate)
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata)
|
||||
v.addEventListener("progress", onProgress)
|
||||
v.addEventListener("ended", onEnded)
|
||||
document.addEventListener("fullscreenchange", onFSChange)
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay)
|
||||
v.removeEventListener("pause", onPause)
|
||||
v.removeEventListener("timeupdate", onTimeUpdate)
|
||||
v.removeEventListener("loadedmetadata", onLoadedMetadata)
|
||||
v.removeEventListener("progress", onProgress)
|
||||
v.removeEventListener("ended", onEnded)
|
||||
document.removeEventListener("fullscreenchange", onFSChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 播放时自动隐藏/显示控制条 */
|
||||
useEffect(() => {
|
||||
resetHideTimer()
|
||||
return () => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
}, [isPlaying, resetHideTimer])
|
||||
|
||||
const progress = Math.round((currentTime / (duration || 1)) * 100)
|
||||
const bufferedPct = Math.round((buffered / (duration || 1)) * 100)
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
progressRef,
|
||||
containerRef,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
showControls,
|
||||
isFullscreen,
|
||||
progress,
|
||||
bufferedPct,
|
||||
togglePlay,
|
||||
handleSeek,
|
||||
handleVolumeChange,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
resetHideTimer,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user