feat(ui): 改造成片库页面
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 204h15m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 204h17m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 204h17m26s

- 重写 ProductLibrary.tsx 为 V21 设计系统风格
- 卡片网格布局(4列响应式),支持视频缩略图展示
- 视频播放弹窗(mock),含播放/暂停、进度条、ESC关闭
- 批量操作:全选、批量发布/下载/删除
- 筛选:状态、时间、时长、搜索
- 已发布徽章(右上角绿色)、选中高亮
- 查重率显示(good/warn/bad 三色)
- 新建 products.css,完整 V21 样式 + 深色模式支持
- 使用 mock 数据,后端 API 对接暂不要求

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
灵应
2026-07-01 00:36:59 +08:00
parent 260911c1ad
commit dc2555bae4
2 changed files with 1381 additions and 266 deletions
+682 -266
View File
@@ -1,289 +1,705 @@
/**
* 成库页面
* 展示用户生成的成品视频,支持查重率显示
* 成库页面 — V21 设计系统
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
* 使用 mock 数据,后端 API 对接暂不要求
*/
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react";
import { message, Popconfirm } from "antd";
import {
Card,
Button,
Typography,
Space,
Row,
Col,
Tag,
Empty,
Spin,
Modal,
Popconfirm,
message,
Progress,
Image,
} from "antd";
import {
DeleteOutlined,
SearchOutlined,
VideoCameraOutlined,
DownloadOutlined,
ShareAltOutlined,
DeleteOutlined,
PlayCircleOutlined,
EyeOutlined,
PauseCircleOutlined,
CloseOutlined,
CheckOutlined,
CloudUploadOutlined,
} from "@ant-design/icons";
import {
getProducts,
deleteProduct,
getProductDownloadUrl,
type ProductItem,
} from "@/api/products";
import PageHead from "@/components/layout/PageHead";
import { Button, Input, Select } from "@/components/ui";
import "./products.css";
const { Text } = Typography;
/* ============================================================
* 类型
* ============================================================ */
type ProductStatus = "completed" | "processing" | "review" | "failed";
/** 状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const config: Record<string, { color: string; text: string }> = {
completed: { color: "success", text: "已完成" },
processing: { color: "processing", text: "处理中" },
failed: { color: "error", text: "失败" },
};
const c = config[status] || config.processing;
return <Tag color={c.color}>{c.text}</Tag>;
interface ProductItem {
id: string;
name: string;
status: ProductStatus;
duration: number; // 秒
date: string;
gradient: string;
duplicateRate: number; // 查重率百分比
isPublished: boolean;
resolution: string;
fileSize: number; // MB
}
/* ============================================================
* Mock 数据
* ============================================================ */
const GRADIENTS = [
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
"linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)",
"linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)",
"linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)",
"linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)",
"linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)",
"linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)",
"linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)",
"linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)",
"linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)",
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
];
const MOCK_PRODUCTS: ProductItem[] = [
{ id: "p-1", name: "产品介绍_智能客服系统_v2", status: "completed", duration: 95, date: "2026-06-28", gradient: GRADIENTS[0], duplicateRate: 3.2, isPublished: true, resolution: "1080×1920", fileSize: 45.6 },
{ id: "p-2", name: "品牌宣传_春季活动", status: "completed", duration: 60, date: "2026-06-27", gradient: GRADIENTS[1], duplicateRate: 5.8, isPublished: true, resolution: "1080×1920", fileSize: 32.1 },
{ id: "p-3", name: "教程_如何使用AI生成视频", status: "completed", duration: 180, date: "2026-06-26", gradient: GRADIENTS[2], duplicateRate: 1.5, isPublished: false, resolution: "1080×1920", fileSize: 78.3 },
{ id: "p-4", name: "电商推广_夏日特惠", status: "processing", duration: 45, date: "2026-06-25", gradient: GRADIENTS[3], duplicateRate: 0, isPublished: false, resolution: "1080×1920", fileSize: 0 },
{ id: "p-5", name: "知识科普_量子计算入门", status: "completed", duration: 120, date: "2026-06-24", gradient: GRADIENTS[4], duplicateRate: 8.1, isPublished: false, resolution: "1080×1920", fileSize: 56.2 },
{ id: "p-6", name: "企业宣传片_科技创新", status: "review", duration: 150, date: "2026-06-23", gradient: GRADIENTS[5], duplicateRate: 12.5, isPublished: false, resolution: "1080×1920", fileSize: 68.9 },
{ id: "p-7", name: "短视频_产品开箱测评", status: "completed", duration: 75, date: "2026-06-22", gradient: GRADIENTS[6], duplicateRate: 6.3, isPublished: true, resolution: "1080×1920", fileSize: 38.7 },
{ id: "p-8", name: "Vlog_日常工作记录", status: "completed", duration: 200, date: "2026-06-21", gradient: GRADIENTS[7], duplicateRate: 2.1, isPublished: false, resolution: "1080×1920", fileSize: 92.4 },
{ id: "p-9", name: "广告_新品发布会预告", status: "failed", duration: 30, date: "2026-06-20", gradient: GRADIENTS[8], duplicateRate: 0, isPublished: false, resolution: "1080×1920", fileSize: 0 },
{ id: "p-10", name: "教育课程_AI基础讲解", status: "completed", duration: 300, date: "2026-06-19", gradient: GRADIENTS[9], duplicateRate: 4.7, isPublished: false, resolution: "1080×1920", fileSize: 125.8 },
{ id: "p-11", name: "直播带货_美妆专场", status: "review", duration: 90, date: "2026-06-18", gradient: GRADIENTS[10], duplicateRate: 15.3, isPublished: false, resolution: "1080×1920", fileSize: 42.1 },
{ id: "p-12", name: "品牌故事_创始人访谈", status: "completed", duration: 240, date: "2026-06-17", gradient: GRADIENTS[11], duplicateRate: 7.9, isPublished: false, resolution: "1080×1920", fileSize: 108.5 },
];
/* ============================================================
* 工具函数
* ============================================================ */
const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
completed: { text: "已完成", className: "completed" },
processing: { text: "处理中", className: "processing" },
review: { text: "待复核", className: "review" },
failed: { text: "失败", className: "failed" },
};
const ProductLibrary: React.FC = () => {
const queryClient = useQueryClient();
const [previewProduct, setPreviewProduct] = useState<ProductItem | null>(
null,
);
/** 格式化时间 mm:ss */
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
// 获取成品列表
const { data: products = [], isLoading } = useQuery({
queryKey: ["products"],
queryFn: getProducts,
});
/** 格式化文件大小 */
const formatSize = (mb: number): string => {
if (mb <= 0) return "-";
if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`;
return `${mb.toFixed(1)} MB`;
};
// 删除成品
const deleteMutation = useMutation({
mutationFn: deleteProduct,
onSuccess: () => {
message.success("已删除");
queryClient.invalidateQueries({ queryKey: ["products"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
},
});
/* ============================================================
* ProductCard 组件
* ============================================================ */
const ProductCard: React.FC<{
product: ProductItem;
isSelected: boolean;
batchMode: boolean;
onToggleSelect: (id: string) => void;
onPlay: (product: ProductItem) => void;
onDownload: (product: ProductItem) => void;
onShare: (product: ProductItem) => void;
onDelete: (id: string) => void;
onPublish: (product: ProductItem) => void;
}> = ({ product, isSelected, batchMode, onToggleSelect, onPlay, onDownload, onShare, onDelete, onPublish }) => {
const st = statusConfig[product.status];
// 下载
const handleDownload = async (productId: string) => {
try {
const { url } = await getProductDownloadUrl(productId);
window.open(url, "_blank");
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("获取下载链接失败");
/** 查重率样式 */
const dupClass = product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad";
/** 点击卡片 */
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(product.id);
} else {
onPlay(product);
}
};
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<PageHead title="成品库" />
/** 点击复选框 */
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleSelect(product.id);
};
{isLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" />
</div>
) : products.length === 0 ? (
<Empty description="暂无成品,去一键生成吧">
<Button
type="primary"
onClick={() => (window.location.href = "/generate")}
>
</Button>
</Empty>
) : (
<Row gutter={[16, 16]}>
{products.map((product) => (
<Col xs={24} sm={12} md={8} lg={6} key={product.id}>
<Card
hoverable
size="small"
cover={
product.thumbnail_url ? (
<Image
src={product.thumbnail_url}
alt={product.title}
style={{ height: 180, objectFit: "cover" }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+"
/>
) : (
<div
style={{
height: 180,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
}}
>
<PlayCircleOutlined
style={{ fontSize: 48, color: "#bbb" }}
/>
</div>
)
}
actions={[
<Button
key="preview"
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => setPreviewProduct(product)}
/>,
<Button
key="download"
type="text"
size="small"
icon={<DownloadOutlined />}
onClick={() => handleDownload(product.id)}
disabled={product.status !== "completed"}
/>,
<Popconfirm
key="delete"
title="确定删除此成品?"
onConfirm={() => deleteMutation.mutate(product.id)}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>,
]}
>
<Card.Meta
title={
<Text ellipsis style={{ maxWidth: "100%" }}>
{product.title}
</Text>
}
description={
<Space
direction="vertical"
size={4}
style={{ width: "100%" }}
>
<Space>
<StatusTag status={product.status} />
{product.duration_seconds && (
<Tag>{product.duration_seconds}s</Tag>
)}
</Space>
{/* 查重率 */}
{product.duplicate_rate !== undefined &&
product.duplicate_rate !== null && (
<div style={{ marginTop: 4 }}>
<Text style={{ fontSize: 12 }}>
{product.duplicate_rate.toFixed(1)}%
</Text>
<Progress
percent={Math.round(product.duplicate_rate)}
size="small"
status={
product.duplicate_rate <= 10
? "success"
: product.duplicate_rate <= 30
? "normal"
: "exception"
}
/>
</div>
)}
</Space>
}
/>
</Card>
</Col>
))}
</Row>
return (
<div
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
onClick={handleCardClick}
>
{/* 复选框(左上角) */}
<div
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
onClick={handleCheckboxClick}
title={isSelected ? "取消选择" : "选择"}
>
{isSelected && <CheckOutlined />}
</div>
{/* 已发布徽章(右上角) */}
{product.isPublished && (
<div className="xx-product-badge"> </div>
)}
{/* 预览弹窗 */}
<Modal
title={previewProduct?.title}
open={!!previewProduct}
onCancel={() => setPreviewProduct(null)}
footer={
<Space>
<Button
icon={<DownloadOutlined />}
onClick={() =>
previewProduct && handleDownload(previewProduct.id)
}
disabled={previewProduct?.status !== "completed"}
>
</Button>
<Button type="primary" onClick={() => setPreviewProduct(null)}>
</Button>
</Space>
}
>
{previewProduct && (
<Space direction="vertical" style={{ width: "100%" }}>
{previewProduct.video_url ? (
<video
src={previewProduct.video_url}
controls
style={{ width: "100%", maxHeight: 400 }}
/>
) : (
<div
style={{
height: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
}}
>
<Text type="secondary"></Text>
</div>
)}
<Row gutter={16}>
<Col span={12}>
<Text type="secondary"></Text>
<Text>{previewProduct.resolution || "-"}</Text>
</Col>
<Col span={12}>
<Text type="secondary"></Text>
<Text>
{previewProduct.duration_seconds
? `${previewProduct.duration_seconds}s`
: "-"}
</Text>
</Col>
<Col span={12}>
<Text type="secondary"></Text>
<Text>
{previewProduct.file_size
? `${(previewProduct.file_size / 1024 / 1024).toFixed(1)} MB`
: "-"}
</Text>
</Col>
<Col span={12}>
<Text type="secondary"></Text>
<Text>
{previewProduct.duplicate_rate !== undefined
? `${previewProduct.duplicate_rate.toFixed(1)}%`
: "-"}
</Text>
</Col>
</Row>
</Space>
{/* 缩略图 */}
<div className="xx-product-thumb">
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
<div className="xx-product-play">
<PlayCircleOutlined />
</div>
{product.duration > 0 && (
<span className="xx-product-duration">{formatTime(product.duration)}</span>
)}
</Modal>
</div>
{/* 信息区 */}
<div className="xx-product-info">
<h4 className="xx-product-title" title={product.name}>
{product.name}
</h4>
<div className="xx-product-meta">
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
<span className="xx-product-date">{product.date}</span>
</div>
{product.duplicateRate > 0 && (
<span className={`xx-product-dup-rate ${dupClass}`}>
{product.duplicateRate.toFixed(1)}%
</span>
)}
</div>
{/* 操作按钮 */}
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
<button
className="xx-product-action-btn"
onClick={() => onDownload(product)}
disabled={product.status !== "completed"}
title="下载"
>
<DownloadOutlined />
</button>
<button
className="xx-product-action-btn"
onClick={() => onShare(product)}
disabled={product.status !== "completed"}
title="分享"
>
<ShareAltOutlined />
</button>
{product.isPublished ? (
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
</span>
) : (
<button
className="xx-product-action-btn primary"
onClick={() => onPublish(product)}
disabled={product.status !== "completed"}
title="发布"
>
<CloudUploadOutlined />
</button>
)}
</div>
{/* 删除按钮(不在批量模式下显示) */}
{!batchMode && (
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
<Popconfirm
title={`确定删除"${product.name}"`}
onConfirm={() => onDelete(product.id)}
okText="删除"
cancelText="取消"
>
<button
className="xx-product-action-btn"
style={{ width: "100%", color: "#dc2626" }}
title="删除"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
)}
</div>
);
};
/* ============================================================
* VideoPlayer 弹窗组件
* ============================================================ */
const VideoPlayer: React.FC<{
product: ProductItem;
onClose: () => void;
onDownload: (product: ProductItem) => void;
onShare: (product: ProductItem) => void;
}> = ({ product, onClose, onDownload, onShare }) => {
const progressRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration] = useState(product.duration);
const intervalRef = useRef<number | null>(null);
/** Mock 播放控制 */
const handlePlayPause = useCallback(() => {
if (isPlaying) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsPlaying(false);
} else {
setIsPlaying(true);
intervalRef.current = window.setInterval(() => {
setCurrentTime((prev) => {
if (prev >= duration) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsPlaying(false);
return 0;
}
return prev + 0.1;
});
}, 100);
}
}, [isPlaying, duration]);
/** 进度条点击 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
setCurrentTime(percent * duration);
};
/** 关闭时清理 */
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
/** ESC 关闭 */
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="xx-player-overlay" onClick={onClose}>
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
{/* 视频区域 */}
<div className="xx-player-video-wrap">
{/* Mock 视频 — 用渐变背景代替 */}
<div
style={{
width: "100%",
height: "100%",
background: product.gradient,
display: "grid",
placeItems: "center",
color: "rgba(255,255,255,0.3)",
fontSize: "64px",
}}
>
<VideoCameraOutlined />
</div>
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={handlePlayPause}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 关闭按钮 */}
<button className="xx-player-close" onClick={onClose}>
<CloseOutlined />
</button>
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div
ref={progressRef}
className="xx-player-progress"
onClick={handleProgressClick}
>
<div
className="xx-player-progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
</div>
{/* 信息区 */}
<div className="xx-player-info">
<h3 className="xx-player-title">{product.name}</h3>
<div className="xx-player-details">
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{product.resolution}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div>
</div>
{/* 底部操作 */}
<div className="xx-player-footer">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={() => onDownload(product)}
disabled={product.status !== "completed"}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ShareAltOutlined />}
onClick={() => onShare(product)}
disabled={product.status !== "completed"}
>
</Button>
<div style={{ flex: 1 }} />
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
</Button>
</div>
</div>
</div>
);
};
/* ============================================================
* 主组件
* ============================================================ */
const ProductLibrary: React.FC = () => {
/* 数据 */
const [products, setProducts] = useState<ProductItem[]>(MOCK_PRODUCTS);
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [filterTime, setFilterTime] = useState<string>("all");
const [filterDuration, setFilterDuration] = useState<string>("all");
/* 批量操作 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
/* 播放器 */
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null);
/* 派生数据 */
const batchMode = selectedIds.size > 0;
const filteredProducts = useMemo(() => {
let list = products;
/* 状态筛选 */
if (filterStatus !== "all") {
list = list.filter((p) => p.status === filterStatus);
}
/* 时间筛选 */
if (filterTime !== "all") {
const now = new Date("2026-07-01");
list = list.filter((p) => {
const d = new Date(p.date);
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
switch (filterTime) {
case "today": return diff < 1;
case "week": return diff <= 7;
case "month": return diff <= 30;
default: return true;
}
});
}
/* 时长筛选 */
if (filterDuration !== "all") {
list = list.filter((p) => {
switch (filterDuration) {
case "short": return p.duration > 0 && p.duration <= 60;
case "medium": return p.duration > 60 && p.duration <= 180;
case "long": return p.duration > 180;
default: return true;
}
});
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
list = list.filter((p) => p.name.toLowerCase().includes(q));
}
return list;
}, [products, filterStatus, filterTime, filterDuration, searchText]);
/* 全选 */
const allSelected = filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id));
const handleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredProducts.map((p) => p.id)));
}
};
/* 切换单个选择 */
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
/* 下载 */
const handleDownload = (product: ProductItem) => {
if (product.status !== "completed") return;
message.success(`正在下载"${product.name}"mock`);
};
/* 分享 */
const handleShare = (product: ProductItem) => {
if (product.status !== "completed") return;
const link = `https://xiaoxia.ai/share/${product.id}`;
navigator.clipboard?.writeText(link).then(
() => message.success(`分享链接已复制:${link}`),
() => message.success(`分享链接:${link}(请手动复制)`)
);
};
/* 删除 */
const handleDelete = (id: string) => {
setProducts((prev) => prev.filter((p) => p.id !== id));
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
message.success("已删除");
};
/* 发布 */
const handlePublish = (product: ProductItem) => {
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? { ...p, isPublished: true } : p))
);
message.success(`"${product.name}" 已发布`);
};
/* 批量下载 */
const handleBatchDownload = () => {
const count = selectedIds.size;
message.success(`正在批量下载 ${count} 个视频(mock`);
setSelectedIds(new Set());
};
/* 批量删除 */
const handleBatchDelete = () => {
const count = selectedIds.size;
setProducts((prev) => prev.filter((p) => !selectedIds.has(p.id)));
setSelectedIds(new Set());
message.success(`已批量删除 ${count} 个视频`);
};
/* 批量发布 */
const handleBatchPublish = () => {
const ids = Array.from(selectedIds);
setProducts((prev) =>
prev.map((p) => (ids.includes(p.id) ? { ...p, isPublished: true } : p))
);
message.success(`已批量发布 ${ids.length} 个视频`);
setSelectedIds(new Set());
};
return (
<div className="xx-products-page">
{/* 页面头部 */}
<div className="xx-products-header">
<h2><VideoCameraOutlined /> </h2>
<div style={{ display: "flex", gap: "var(--space-sm)" }}>
<Button buttonType="ghost" buttonSize="sm" icon={<DownloadOutlined />}>
</Button>
</div>
</div>
{/* 批量操作栏 */}
{batchMode && (
<div className="xx-products-batch-bar">
<div className="xx-products-batch-bar-left">
<div
className={`xx-products-checkbox${allSelected ? " checked" : ""}`}
onClick={handleSelectAll}
>
{allSelected && <CheckOutlined />}
</div>
<span className="xx-products-select-all" onClick={handleSelectAll}>
{allSelected ? "取消全选" : "全选"}
</span>
<span className="xx-products-batch-count">
{selectedIds.size}
</span>
</div>
<div className="xx-products-batch-bar-right">
<Button buttonType="ghost" buttonSize="sm" icon={<DownloadOutlined />} onClick={handleBatchDownload}>
</Button>
<Button buttonType="primary" buttonSize="sm" icon={<CloudUploadOutlined />} onClick={handleBatchPublish}>
</Button>
<Popconfirm
title={`确定删除选中的 ${selectedIds.size} 个视频?`}
onConfirm={handleBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Button buttonType="ghost" buttonSize="sm" onClick={() => setSelectedIds(new Set())}>
</Button>
</div>
</div>
)}
{/* 筛选栏 */}
<div className="xx-products-filters">
<div className="xx-products-filters-left">
<Input
placeholder="搜索成片名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterStatus}
onChange={setFilterStatus}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部状态" },
{ value: "completed", label: "已完成" },
{ value: "processing", label: "处理中" },
{ value: "review", label: "待复核" },
{ value: "failed", label: "失败" },
]}
/>
<Select
value={filterTime}
onChange={setFilterTime}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]}
/>
<Select
value={filterDuration}
onChange={setFilterDuration}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时长" },
{ value: "short", label: "≤1分钟" },
{ value: "medium", label: "1-3分钟" },
{ value: "long", label: ">3分钟" },
]}
/>
</div>
<div className="xx-products-filters-right">
<span style={{ fontSize: "var(--font-size-sm)", color: "var(--text-tertiary)" }}>
{filteredProducts.length}
</span>
</div>
</div>
{/* 卡片网格 */}
{filteredProducts.length > 0 ? (
<div className="xx-products-grid">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
isSelected={selectedIds.has(product.id)}
batchMode={batchMode}
onToggleSelect={handleToggleSelect}
onPlay={setPlayingProduct}
onDownload={handleDownload}
onShare={handleShare}
onDelete={handleDelete}
onPublish={handlePublish}
/>
))}
</div>
) : (
<div className="xx-products-empty">
<div className="xx-products-empty-icon">
<VideoCameraOutlined />
</div>
<p></p>
<Button buttonType="primary" buttonSize="sm" onClick={() => message.info("跳转到生成页面(mock")}>
</Button>
</div>
)}
{/* 视频播放弹窗 */}
{playingProduct && (
<VideoPlayer
product={playingProduct}
onClose={() => setPlayingProduct(null)}
onDownload={handleDownload}
onShare={handleShare}
/>
)}
</div>
);
};
+699
View File
@@ -0,0 +1,699 @@
/**
* 成片库页面 - V21 设计系统样式
* 卡片网格布局,支持批量操作、视频播放弹窗
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-products-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
页面头部
============================================================ */
.xx-products-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
}
.xx-products-header h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
/* ============================================================
批量操作栏
============================================================ */
.xx-products-batch-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px 16px;
background: var(--primary-soft);
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
animation: batch-bar-in 0.2s ease-out;
}
@keyframes batch-bar-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.xx-products-batch-bar-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-products-batch-bar-right {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-left: auto;
}
.xx-products-batch-count {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--primary-color);
}
/* 全选复选框 */
.xx-products-select-all {
display: flex;
align-items: center;
gap: var(--space-xs);
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--text-secondary);
user-select: none;
}
.xx-products-checkbox {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
transition: var(--transition-all);
background: var(--bg-primary);
flex-shrink: 0;
}
.xx-products-checkbox:hover {
border-color: var(--primary-color);
}
.xx-products-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
筛选栏
============================================================ */
.xx-products-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
margin-bottom: var(--space-lg);
}
.xx-products-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-products-filters-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ============================================================
卡片网格(4列)
============================================================ */
.xx-products-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
/* ============================================================
成片卡片
============================================================ */
.xx-product-card {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-md);
overflow: hidden;
transition: var(--transition-all);
position: relative;
cursor: pointer;
}
.xx-product-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
/* 选中状态 */
.xx-product-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-soft), var(--shadow-sm);
}
/* 已发布状态 - 降低透明度 */
.xx-product-card.published {
opacity: 0.65;
}
.xx-product-card.published:hover {
opacity: 0.85;
}
/* 卡片复选框(左上角) */
.xx-product-card-checkbox {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
width: 22px;
height: 22px;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
cursor: pointer;
transition: var(--transition-all);
color: #fff;
font-size: 12px;
}
.xx-product-card-checkbox:hover {
border-color: var(--primary-color);
background: rgba(0, 0, 0, 0.5);
}
.xx-product-card-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
/* 已发布徽章(右上角) */
.xx-product-badge {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
background: rgba(16, 185, 129, 0.9);
color: #fff;
backdrop-filter: blur(4px);
}
/* 缩略图区域 */
.xx-product-thumb {
aspect-ratio: 9 / 16;
max-height: 220px;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: #fff;
}
.xx-product-thumb-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
}
.xx-product-play {
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
font-size: 18px;
transition: var(--transition-all);
}
.xx-product-card:hover .xx-product-play {
background: var(--primary-color);
transform: scale(1.1);
}
/* 时长标签 */
.xx-product-duration {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 1;
padding: 2px 8px;
border-radius: var(--radius-xs);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
background: rgba(0, 0, 0, 0.65);
color: #fff;
backdrop-filter: blur(4px);
}
/* 卡片信息区 */
.xx-product-info {
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-product-title {
margin: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-product-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
}
/* 状态标签 */
.xx-product-status {
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.xx-product-status.completed {
background: #ecfdf5;
color: #059669;
}
.xx-product-status.processing {
background: var(--primary-soft);
color: var(--primary-color);
}
.xx-product-status.review {
background: #fefce8;
color: #ca8a04;
}
.xx-product-status.failed {
background: #fef2f2;
color: #dc2626;
}
/* 深色模式状态标签 */
.dark .xx-product-status.completed,
[data-theme="dark"] .xx-product-status.completed {
background: rgba(5, 150, 105, 0.15);
color: #34d399;
}
.dark .xx-product-status.processing,
[data-theme="dark"] .xx-product-status.processing {
background: var(--primary-soft);
color: var(--primary-color);
}
.dark .xx-product-status.review,
[data-theme="dark"] .xx-product-status.review {
background: rgba(202, 138, 4, 0.15);
color: #facc15;
}
.dark .xx-product-status.failed,
[data-theme="dark"] .xx-product-status.failed {
background: rgba(220, 38, 38, 0.15);
color: #f87171;
}
/* 日期 */
.xx-product-date {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 查重率 */
.xx-product-dup-rate {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.xx-product-dup-rate.good {
color: #059669;
}
.xx-product-dup-rate.warn {
color: #ca8a04;
}
.xx-product-dup-rate.bad {
color: #dc2626;
}
/* 操作按钮区 */
.xx-product-actions {
display: flex;
gap: 6px;
padding: 0 14px 14px;
}
.xx-product-action-btn {
flex: 1;
padding: 6px 4px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
}
.xx-product-action-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
.xx-product-action-btn.primary {
background: var(--gradient-primary);
color: #fff;
border: none;
}
.xx-product-action-btn.primary:hover {
box-shadow: var(--shadow-primary);
transform: translateY(-1px);
}
.xx-product-action-btn.success {
background: #059669;
color: #fff;
border: none;
}
.xx-product-action-btn.success:hover {
background: #047857;
}
.xx-product-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ============================================================
视频播放弹窗
============================================================ */
.xx-player-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
animation: player-fade-in 0.25s ease-out;
}
@keyframes player-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.xx-player-container {
width: 90%;
max-width: 540px;
background: var(--bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.3);
animation: player-scale-in 0.25s ease-out;
}
@keyframes player-scale-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
.xx-player-video-wrap {
position: relative;
background: #000;
aspect-ratio: 9 / 16;
max-height: 60vh;
display: grid;
place-items: center;
}
.xx-player-video-wrap video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 播放/暂停大按钮 */
.xx-player-play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: #fff;
font-size: 28px;
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
z-index: 2;
}
.xx-player-play-btn:hover {
background: var(--primary-color);
transform: translate(-50%, -50%) scale(1.1);
}
/* 进度条 */
.xx-player-progress-wrap {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
z-index: 2;
}
.xx-player-progress {
height: 4px;
background: rgba(255, 255, 255, 0.25);
border-radius: var(--radius-full);
overflow: hidden;
cursor: pointer;
}
.xx-player-progress-bar {
height: 100%;
background: var(--gradient-primary);
border-radius: var(--radius-full);
transition: width 0.1s linear;
}
.xx-player-time {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
/* 弹窗信息区 */
.xx-player-info {
padding: 16px;
}
.xx-player-title {
margin: 0 0 10px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-player-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.xx-player-detail-item {
font-size: var(--font-size-sm);
}
.xx-player-detail-label {
color: var(--text-tertiary);
}
.xx-player-detail-value {
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
/* 弹窗底部操作 */
.xx-player-footer {
display: flex;
gap: var(--space-sm);
padding: 0 16px 16px;
}
/* 关闭按钮 */
.xx-player-close {
position: absolute;
top: 12px;
right: 12px;
z-index: 3;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: #fff;
font-size: 16px;
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
}
.xx-player-close:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
/* ============================================================
空状态
============================================================ */
.xx-products-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--text-secondary);
}
.xx-products-empty-icon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1400px) {
.xx-products-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 992px) {
.xx-products-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-products-page {
padding: var(--space-md);
}
.xx-products-filters {
flex-direction: column;
align-items: stretch;
}
.xx-products-filters-left,
.xx-products-filters-right {
justify-content: flex-start;
}
.xx-products-batch-bar {
flex-direction: column;
align-items: flex-start;
}
.xx-products-batch-bar-right {
margin-left: 0;
width: 100%;
}
}
@media (max-width: 576px) {
.xx-products-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.xx-product-info {
padding: 10px;
}
.xx-product-actions {
padding: 0 10px 10px;
flex-direction: column;
}
.xx-player-container {
width: 95%;
}
}
@media (max-width: 480px) {
.xx-products-page {
padding: var(--space-sm);
}
.xx-products-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
}