🔴 P0: 修复生成视频错误提示 + 成片库详情页404
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
- P0-6: GeneratePage 改善错误处理,展示后端详细错误信息 - P0-7: 新建 ProductDetail 产品详情页 - 视频播放器(自定义控制条:播放/暂停/进度/音量/全屏) - 产品信息网格(标题/状态/时长/分辨率/文件大小/查重率/创建时间) - 下载/分享/删除操作按钮 - 添加 products/:id 路由 - ProductLibrary 添加'查看详情'按钮导航
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<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,
|
||||
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();
|
||||
};
|
||||
|
||||
/* ── 加载状态 ── */
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
<div className="xx-detail-loading">
|
||||
<LoadingOutlined style={{ fontSize: 32, color: "var(--primary-color)" }} spin />
|
||||
<p style={{ color: "var(--text-secondary)", marginTop: 16 }}>加载中…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 错误状态 ── */
|
||||
if (isError || !product) {
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
<div className="xx-detail-error">
|
||||
<WarningOutlined style={{ fontSize: 48, color: "var(--error-color)" }} />
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "无法获取产品信息"}</p>
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={() => navigate("/app/products")}>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* ── 主体内容 ── */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { message, Popconfirm } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
CloseOutlined,
|
||||
CheckOutlined,
|
||||
CloudUploadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import {
|
||||
@@ -311,7 +313,8 @@ const VideoPlayer: React.FC<{
|
||||
onClose: () => void;
|
||||
onDownload: (product: ProductItem) => void;
|
||||
onShare: (product: ProductItem) => void;
|
||||
}> = ({ product, onClose, onDownload, onShare }) => {
|
||||
onViewDetail: (product: ProductItem) => void;
|
||||
}> = ({ product, onClose, onDownload, onShare, onViewDetail }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -480,6 +483,14 @@ const VideoPlayer: React.FC<{
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
@@ -495,6 +506,7 @@ const VideoPlayer: React.FC<{
|
||||
* ============================================================ */
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
@@ -645,6 +657,12 @@ const ProductLibrary: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null); // 关闭播放器
|
||||
navigate(`/app/products/${product.id}`);
|
||||
};
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id);
|
||||
@@ -912,6 +930,7 @@ const ProductLibrary: React.FC = () => {
|
||||
onClose={() => setPlayingProduct(null)}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -631,6 +631,327 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
产品详情页
|
||||
============================================================ */
|
||||
.xx-product-detail-page {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-detail-loading,
|
||||
.xx-detail-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-detail-error h3 {
|
||||
font-size: 20px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: var(--space-md) 0 0;
|
||||
}
|
||||
|
||||
.xx-detail-error p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.xx-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.xx-detail-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-detail-back-btn:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-detail-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 主体 */
|
||||
.xx-detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: var(--space-lg);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.xx-detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放器 */
|
||||
.xx-detail-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.xx-detail-player video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.xx-detail-player.is-fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.xx-detail-player-empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-detail-player-empty img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.xx-detail-player-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-detail-player-placeholder p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 播放器控制条 */
|
||||
.xx-detail-player-controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
padding: var(--space-lg) var(--space-md) var(--space-md);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-detail-player-controls.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.xx-dp-progress {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-dp-progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: height var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-dp-progress:hover .xx-dp-progress-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.xx-dp-progress-buffered {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.xx-dp-progress-played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 控制栏 */
|
||||
.xx-dp-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-dp-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-dp-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.xx-dp-time {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-dp-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider {
|
||||
width: 72px;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 产品信息面板 */
|
||||
.xx-detail-info {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.xx-detail-title {
|
||||
font-size: 22px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-lg);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.xx-detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-detail-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-detail-meta-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-detail-meta-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 详情页响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.xx-product-detail-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-detail-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.xx-detail-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xx-detail-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
|
||||
@@ -127,6 +127,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "products/:id",
|
||||
lazy: () =>
|
||||
import("@/pages/products/ProductDetail").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "editing-planner",
|
||||
lazy: () =>
|
||||
|
||||
Reference in New Issue
Block a user