🔴 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

- P0-6: GeneratePage 改善错误处理,展示后端详细错误信息
- P0-7: 新建 ProductDetail 产品详情页
  - 视频播放器(自定义控制条:播放/暂停/进度/音量/全屏)
  - 产品信息网格(标题/状态/时长/分辨率/文件大小/查重率/创建时间)
  - 下载/分享/删除操作按钮
  - 添加 products/:id 路由
  - ProductLibrary 添加'查看详情'按钮导航
This commit is contained in:
灵应
2026-07-06 12:55:56 +08:00
parent a0e9441f82
commit 14cf4a4d86
5 changed files with 870 additions and 29 deletions
+57 -28
View File
@@ -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;
+20 -1
View File
@@ -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>
+321
View File
@@ -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;
}
}
/* ============================================================
响应式
============================================================ */
+7
View File
@@ -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: () =>