From 14cf4a4d869c7e2213ad0d866e43e80d2fcfad02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Mon, 6 Jul 2026 12:55:56 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=94=B4=20P0:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=A7=86=E9=A2=91=E9=94=99=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=20+=20=E6=88=90=E7=89=87=E5=BA=93=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0-6: GeneratePage 改善错误处理,展示后端详细错误信息 - P0-7: 新建 ProductDetail 产品详情页 - 视频播放器(自定义控制条:播放/暂停/进度/音量/全屏) - 产品信息网格(标题/状态/时长/分辨率/文件大小/查重率/创建时间) - 下载/分享/删除操作按钮 - 添加 products/:id 路由 - ProductLibrary 添加'查看详情'按钮导航 --- apps/web/src/pages/generate/GeneratePage.tsx | 85 ++-- apps/web/src/pages/products/ProductDetail.tsx | 465 ++++++++++++++++++ .../web/src/pages/products/ProductLibrary.tsx | 21 +- apps/web/src/pages/products/products.css | 321 ++++++++++++ apps/web/src/router/index.tsx | 7 + 5 files changed, 870 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/pages/products/ProductDetail.tsx diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index da0ebc082..626c3e3b3 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -412,43 +412,72 @@ const GeneratePage: React.FC = () => { await generateEditPlan(plan.id); const poll = async () => { - const status = await apiClient.get( - `/edit-plans/${plan.id}/generation-status`, - ); - const data = status.data; + try { + const status = await apiClient.get( + `/edit-plans/${plan.id}/generation-status`, + ); + const data = status.data; - if (data.plan_status === "completed") { - setProgress(100); - setGenerating(false); - setGenerated(true); - message.success("视频生成完成!"); - return; + if (data.plan_status === "completed") { + setProgress(100); + setGenerating(false); + setGenerated(true); + message.success("视频生成完成!"); + return; + } + if (data.plan_status === "failed") { + setGenerating(false); + // 提取后端返回的错误详情,便于排查 + const errorMsg = + data.error_message || + data.error || + data.message || + (data.clips || []).find( + (c: { status: string }) => c.status === "failed", + )?.error_message || + "视频生成失败,请联系管理员或重试"; + console.error("[生成失败] planId:", plan.id, "响应:", data); + message.error(errorMsg); + return; + } + + const clips = data.clips || []; + const total = clips.length || 1; + const done = clips.filter( + (c: { status: string }) => c.status === "completed", + ).length; + setProgress(Math.round((done / total) * 100)); + + progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< + typeof setInterval + >; + } catch (pollErr) { + // 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次 + console.error("[轮询出错] planId:", plan.id, pollErr); + progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType< + typeof setInterval + >; } - if (data.plan_status === "failed") { - setGenerating(false); - message.error("视频生成失败"); - return; - } - - const clips = data.clips || []; - const total = clips.length || 1; - const done = clips.filter( - (c: { status: string }) => c.status === "completed", - ).length; - setProgress(Math.round((done / total) * 100)); - - progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< - typeof setInterval - >; }; progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< typeof setInterval >; - } catch (err) { + } catch (err: unknown) { console.error("生成失败:", err); setGenerating(false); - message.error("生成失败,请重试"); + // 提取 axios 响应中的后端错误信息 + const axiosErr = err as { + response?: { + data?: { message?: string; error?: string; detail?: string }; + }; + }; + const backendMsg = + axiosErr.response?.data?.message || + axiosErr.response?.data?.error || + axiosErr.response?.data?.detail || + ""; + message.error(backendMsg || "生成失败,请重试"); } }, [ title, diff --git a/apps/web/src/pages/products/ProductDetail.tsx b/apps/web/src/pages/products/ProductDetail.tsx new file mode 100644 index 000000000..8daf81a9a --- /dev/null +++ b/apps/web/src/pages/products/ProductDetail.tsx @@ -0,0 +1,465 @@ +/** + * 成品库 — 产品详情页 + * 路由:/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 { Button } from "../../components/ui"; +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 = { + 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, + isLoading, + isError, + error, + } = useQuery({ + 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(null); + const progressRef = useRef(null); + const hideTimerRef = useRef>(); + + 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(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) => { + 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) => { + 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(); + }; + + /* ── 加载状态 ── */ + if (isLoading) { + return ( +
+
+ +

加载中…

+
+
+ ); + } + + /* ── 错误状态 ── */ + if (isError || !product) { + return ( +
+
+ +

加载失败

+

{error?.message || "无法获取产品信息"}

+ +
+
+ ); + } + + 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 ( +
+ {/* ── 顶部导航 ── */} +
+ +
+ + + +
+
+ + {/* ── 主体内容 ── */} +
+ {/* 视频播放器 */} +
+ {product.video_url ? ( +