Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoxia a16ba5f93e fix: 更新工具函数导入路径为 detailUtils
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m33s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m34s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m7s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m53s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m43s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m5s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 22s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 15:29:21 +08:00
xiaoxia 2248e9e377 fix: 更新 formatDuration 导入路径为 detailUtils
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
2026-07-27 15:29:19 +08:00
xiaoxia bb9abfa470 fix: 删除 utils.ts(已重命名为 detailUtils.ts) 2026-07-27 15:29:18 +08:00
xiaoxia fe1bcf67df fix: 重命名 utils.ts 为 detailUtils.ts 避免与 utils/ 目录冲突 2026-07-27 15:29:17 +08:00
xiaoxia 078c56ef62 Merge branch 'develop' into refactor/product-detail
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m22s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m30s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m55s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m7s
AI Code Review / AI Code Review (pull_request) Successful in 1m37s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 12:34:49 +08:00
xiaoxia eb999016c8 refactor(product-detail): 三阶段重构 ProductDetail(450→80行, -82%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m7s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m49s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m14s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 58s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m37s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 29s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- Phase 1: 抽 constants(STATUS_MAP) + utils(formatDuration/formatFileSize/formatDate)
- Phase 2: 抽 3 个 UI 组件(DetailHeader/DetailVideoPlayer/ProductInfoPanel)
- Phase 3: 抽 2 个业务 Hook(useProductDetail/useVideoPlayer)
- 主文件 450→80 行,职责清晰,视频播放器逻辑可复用
2026-07-26 17:44:52 +08:00
8 changed files with 557 additions and 399 deletions
+29 -399
View File
@@ -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>
)
}
+8
View File
@@ -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,
}
}