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
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user