feat(ui): 改造素材库页面
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled

- 两栏布局:左侧 260px 素材库列表 + 右侧素材网格
- AssetCard 组件:9:16 缩略图、渐变背景、播放按钮、StatusPill 状态标签
- 筛选功能:按类型(视频/配音/图片)、时间(今天/周/月)、名称搜索
- 批量操作:全选、取消选择、批量删除
- 拖拽上传区域,支持 2GB 限制校验
- 新建/删除素材库功能
- 响应式适配:1400px→3列, 1200px→单栏, 768px→2列, 480px→1列
- 使用 mock 数据,CSS 变量统一主题
This commit is contained in:
灵应
2026-06-30 23:19:20 +08:00
parent 163aa927cb
commit b6c6dedfec
2 changed files with 937 additions and 353 deletions
+483 -353
View File
@@ -1,400 +1,530 @@
/**
* 素材库页面
* 展示用户所有素材,支持响应式上传
* 素材库页面 — V21 设计系统
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
* 使用 mock 数据,后端 API 对接暂不要求
*/
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import React, { useMemo, useState } from "react";
import {
Card,
Button,
Upload,
Modal as AntModal,
message,
Typography,
Space,
Tabs,
Empty,
Spin,
Modal,
Select,
Input,
Row,
Col,
Tag,
Image,
Popconfirm,
} from "antd";
import {
PlusOutlined,
DeleteOutlined,
VideoCameraOutlined,
PictureOutlined,
AudioOutlined,
SearchOutlined,
InboxOutlined,
VideoCameraOutlined,
SoundOutlined,
PictureOutlined,
PlayCircleOutlined,
CheckOutlined,
DeleteOutlined,
ExperimentOutlined,
} from "@ant-design/icons";
import {
getAssetLibraries,
createAssetLibrary,
getAssets,
deleteAsset,
uploadAsset,
uploadAssetDirect,
} from "@/api/assets";
import { getOrCreateDefaultProject } from "@/api/projects";
import PageHead from "@/components/layout/PageHead";
import { Button, Input, Select } from "@/components/ui";
import "./assets.css";
const { Text } = Typography;
const { Dragger } = Upload;
/* ============================================================
* 类型
* ============================================================ */
type AssetKind = "video" | "voice" | "image";
type StatusType = "ok" | "warn" | "bad" | "info";
/** 最大文件大小:2048MB(与后端 OSS_DIRECT_UPLOAD_MAX_MB=2000 对齐,留少量余量) */
const MAX_FILE_SIZE = 2048 * 1024 * 1024; // 2147483648 bytes
/** 大文件阈值:100MB,超过此值走 OSS 直传通道 */
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; // 104857600 bytes
interface LibraryItem {
id: string;
name: string;
kind: AssetKind;
count: number;
}
/** 素材库类型图标 */
const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
interface AssetItem {
id: string;
name: string;
kind: AssetKind;
thumbUrl?: string;
status: StatusType;
statusLabel: string;
duration?: string;
size: number;
createdAt: string;
}
/* ============================================================
* Mock 数据
* ============================================================ */
const MOCK_LIBRARIES: LibraryItem[] = [
{ id: "lib-1", name: "产品演示视频", kind: "video", count: 12 },
{ id: "lib-2", name: "品牌配音素材", kind: "voice", count: 8 },
{ id: "lib-3", name: "营销图片素材", kind: "image", count: 24 },
{ id: "lib-4", name: "用户访谈录像", kind: "video", count: 5 },
];
const MOCK_ASSETS: AssetItem[] = [
{ id: "a-1", name: "产品功能演示_v2.mp4", kind: "video", status: "ok", statusLabel: "合格", duration: "02:34", size: 128, createdAt: "2026-06-28" },
{ id: "a-2", name: "品牌宣传片_终版.mp4", kind: "video", status: "warn", statusLabel: "待优化", duration: "05:12", size: 356, createdAt: "2026-06-27" },
{ id: "a-3", name: "用户案例_张三.mp4", kind: "video", status: "bad", statusLabel: "不合格", duration: "01:48", size: 96, createdAt: "2026-06-26" },
{ id: "a-4", name: "功能讲解_配音.mp3", kind: "voice", status: "ok", statusLabel: "合格", duration: "03:22", size: 48, createdAt: "2026-06-25" },
{ id: "a-5", name: "旁白_中文版.wav", kind: "voice", status: "info", statusLabel: "处理中", duration: "04:05", size: 72, createdAt: "2026-06-24" },
{ id: "a-6", name: "Banner_春季活动.png", kind: "image", status: "ok", statusLabel: "合格", size: 2.4, createdAt: "2026-06-23" },
{ id: "a-7", name: "产品截图_首页.png", kind: "image", status: "ok", statusLabel: "合格", size: 1.8, createdAt: "2026-06-22" },
{ id: "a-8", name: "社交媒体封面.jpg", kind: "image", status: "warn", statusLabel: "待优化", size: 3.2, createdAt: "2026-06-21" },
{ id: "a-9", name: "操作指南_录屏.mp4", kind: "video", status: "ok", statusLabel: "合格", duration: "08:15", size: 512, createdAt: "2026-06-20" },
{ id: "a-10", name: "背景音乐_轻快.mp3", kind: "voice", status: "ok", statusLabel: "合格", duration: "02:00", size: 36, createdAt: "2026-06-19" },
{ id: "a-11", name: "Logo_高清.png", kind: "image", status: "ok", statusLabel: "合格", size: 0.8, createdAt: "2026-06-18" },
{ id: "a-12", name: "活动预告_短视频.mp4", kind: "video", status: "info", statusLabel: "处理中", duration: "00:30", size: 64, createdAt: "2026-06-17" },
];
const MAX_FILE_SIZE = 2048 * 1024 * 1024;
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
/* ============================================================
* 工具函数
* ============================================================ */
const kindIcon = (kind: AssetKind) => {
switch (kind) {
case "video":
return <VideoCameraOutlined style={{ color: "#1890ff" }} />;
case "voice":
return <AudioOutlined style={{ color: "#52c41a" }} />;
case "image":
return <PictureOutlined style={{ color: "#faad14" }} />;
default:
return <VideoCameraOutlined />;
case "video": return <VideoCameraOutlined />;
case "voice": return <SoundOutlined />;
case "image": return <PictureOutlined />;
}
};
/** 素材库类型标签 */
const kindLabel: Record<string, string> = {
video: "视频",
voice: "配音",
image: "图片",
const kindLabel = (kind: AssetKind) => {
switch (kind) {
case "video": return "视频";
case "voice": return "配音";
case "image": return "图片";
}
};
/** 根据素材类型返回渐变背景 */
const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
}
};
/* ============================================================
* StatusPill 组件
* ============================================================ */
const StatusPill: React.FC<{ status: StatusType; label: string }> = ({
status,
label,
}) => (
<span className={`xx-status-pill xx-status-pill-${status}`}>
{label}
</span>
);
/* ============================================================
* AssetCard 组件
* ============================================================ */
const AssetCard: React.FC<{
asset: AssetItem;
selected: boolean;
onToggle: () => void;
onDiagnose: () => void;
}> = ({ asset, selected, onToggle, onDiagnose }) => (
<div
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
onClick={onToggle}
>
{/* 缩略图区 */}
<div
className="xx-asset-thumb"
style={{ background: thumbGradient(asset.kind) }}
>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{kindIcon(asset.kind)}
</span>
)}
{/* 视频/配音类显示播放按钮 */}
{(asset.kind === "video" || asset.kind === "voice") && (
<span className="xx-asset-play">
<PlayCircleOutlined />
</span>
)}
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className="xx-asset-diagnose-btn"
onClick={(e) => {
e.stopPropagation();
onDiagnose();
}}
>
<ExperimentOutlined />
诊断
</button>
</div>
</div>
);
/* ============================================================
* 主组件
* ============================================================ */
const AssetLibrary: React.FC = () => {
const queryClient = useQueryClient();
const [activeLibrary, setActiveLibrary] = useState<string>("");
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newLibName, setNewLibName] = useState("");
const [newLibKind, setNewLibKind] = useState<"video" | "voice" | "image">(
"video",
);
/* 状态 */
const [libraries, setLibraries] = useState<LibraryItem[]>(MOCK_LIBRARIES);
const [activeLibId, setActiveLibId] = useState<string>(MOCK_LIBRARIES[0].id);
const [assets] = useState<AssetItem[]>(MOCK_ASSETS);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterType, setFilterType] = useState<string>("all");
const [filterTime, setFilterTime] = useState<string>("all");
/* 上传 */
const [uploading, setUploading] = useState(false);
// 获取素材库列表
const { data: libraries = [], isLoading: libsLoading } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
/* 新建素材库 */
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newLibName, setNewLibName] = useState("");
const [newLibKind, setNewLibKind] = useState<AssetKind>("video");
// 获取当前素材库的素材
const { data: assets = [], isLoading: assetsLoading } = useQuery({
queryKey: ["assets", activeLibrary],
queryFn: () => getAssets(activeLibrary),
enabled: !!activeLibrary,
});
/* 派生数据 */
const activeLibrary = libraries.find((l) => l.id === activeLibId);
// 创建素材库
const createLibMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: () => {
message.success("素材库创建成功");
setCreateModalOpen(false);
setNewLibName("");
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("创建失败");
},
});
const filteredAssets = useMemo(() => {
let list = assets;
// 上传素材(小文件表单上传)
const uploadMutation = useMutation({
mutationFn: uploadAsset,
onSuccess: () => {
message.success("上传成功");
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败");
},
});
// 大文件直传(OSS 预签名)
const directUploadMutation = useMutation({
mutationFn: uploadAssetDirect,
onSuccess: () => {
message.success("上传成功");
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败");
},
});
// 删除素材
const deleteMutation = useMutation({
mutationFn: deleteAsset,
onSuccess: () => {
message.success("已删除");
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
},
});
/** 处理上传 */
const handleUpload = async (file: File) => {
if (!activeLibrary) {
message.warning("请先选择素材库");
return false;
/* 按素材库类型过滤 */
if (activeLibrary) {
list = list.filter((a) => a.kind === activeLibrary.kind);
}
// 文件大小校验:上限 2GB
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((a) => a.kind === filterType);
}
/* 按时间筛选 */
if (filterTime !== "all") {
const now = new Date("2026-06-30");
list = list.filter((a) => {
const d = new Date(a.createdAt);
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
if (filterTime === "today") return diffDays < 1;
if (filterTime === "week") return diffDays < 7;
if (filterTime === "month") return diffDays < 30;
return true;
});
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
list = list.filter((a) => a.name.toLowerCase().includes(q));
}
return list;
}, [assets, activeLibrary, filterType, filterTime, searchText]);
/* 选择操作 */
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(filteredAssets.map((a) => a.id)));
};
const deselectAll = () => {
setSelectedIds(new Set());
};
/* 上传 */
const handleUpload = (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error("文件大小不能超过 2GB");
message.error(`文件 "${file.name}" 超过 2GB 限制`);
return false;
}
setUploading(true);
try {
if (file.size > LARGE_FILE_THRESHOLD) {
// 大文件(>100MB)走 OSS 预签名直传通道
await directUploadMutation.mutateAsync({
file,
library_id: activeLibrary,
});
} else {
// 小文件走表单上传
const project = await getOrCreateDefaultProject();
const formData = new FormData();
formData.append("file", file);
formData.append("library_id", activeLibrary);
formData.append("project_id", project.id);
await uploadMutation.mutateAsync(formData);
}
} catch {
// mutation.onError 已处理错误提示
} finally {
setUploading(false);
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传(mock)`);
}
setUploading(true);
/* mock 上传 — 1.5s 后完成 */
setTimeout(() => {
setUploading(false);
message.success(`"${file.name}" 上传成功`);
}, 1500);
return false;
};
/** 当前选中的素材库 */
const currentLib = libraries.find((l) => l.id === activeLibrary);
/* 新建素材库 */
const handleCreateLibrary = () => {
if (!newLibName.trim()) {
message.warning("请输入素材库名称");
return;
}
const lib: LibraryItem = {
id: `lib-${Date.now()}`,
name: newLibName.trim(),
kind: newLibKind,
count: 0,
};
setLibraries((prev) => [...prev, lib]);
setActiveLibId(lib.id);
setCreateModalOpen(false);
setNewLibName("");
setNewLibKind("video");
message.success(`素材库 "${lib.name}" 创建成功`);
};
/* 删除素材库 */
const handleDeleteLibrary = (id: string) => {
setLibraries((prev) => prev.filter((l) => l.id !== id));
if (activeLibId === id) {
const remaining = libraries.filter((l) => l.id !== id);
if (remaining.length > 0) setActiveLibId(remaining[0].id);
}
message.success("素材库已删除");
};
/* 诊断 */
const handleDiagnose = (asset: AssetItem) => {
message.info(`正在诊断 "${asset.name}"...(mock)`);
};
/* 批量删除 */
const handleBatchDelete = () => {
message.success(`已删除 ${selectedIds.size} 个素材(mock)`);
deselectAll();
};
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<PageHead
title="素材库"
actions={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
新建素材库
</Button>
}
/>
{libsLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" />
</div>
) : libraries.length === 0 ? (
<Empty description="暂无素材库,点击上方按钮创建">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
创建第一个素材库
</Button>
</Empty>
) : (
<>
{/* 素材库标签页 */}
<Tabs
activeKey={activeLibrary}
onChange={setActiveLibrary}
items={libraries.map((lib) => ({
key: lib.id,
label: (
<Space>
<KindIcon kind={lib.kind} />
<span>{lib.name}</span>
<Tag>{lib.asset_count ?? 0}</Tag>
</Space>
),
}))}
style={{ marginBottom: 16 }}
/>
{/* 上传区域 */}
{activeLibrary && (
<Dragger
accept={
currentLib?.kind === "video"
? "video/*"
: currentLib?.kind === "voice"
? "audio/*"
: "image/*"
}
beforeUpload={handleUpload}
showUploadList={false}
multiple
disabled={
uploading ||
uploadMutation.isPending ||
directUploadMutation.isPending
}
style={{ marginBottom: 24 }}
<div className="xx-assets-page">
{/* 两栏布局 */}
<div className="xx-assets-layout">
{/* ─── 左侧:素材库列表 ─── */}
<div className="xx-asset-library-list">
{libraries.map((lib) => (
<div
key={lib.id}
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
onClick={() => setActiveLibId(lib.id)}
>
<p className="ant-upload-drag-icon">
{uploading ? <Spin /> : <InboxOutlined />}
</p>
<p className="ant-upload-text">
{uploading
? "正在上传,请稍候..."
: "点击或拖拽文件到此区域上传"}
</p>
<p className="ant-upload-hint">
支持 {kindLabel[currentLib?.kind || "video"]}{" "}
格式文件,单文件不超过 2GB
</p>
</Dragger>
)}
{/* 素材列表 */}
{assetsLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
</div>
) : assets.length === 0 ? (
<Empty description="该素材库暂无素材" />
) : (
<Row gutter={[16, 16]}>
{assets.map((asset) => (
<Col xs={24} sm={12} md={8} lg={6} key={asset.id}>
<Card
hoverable
size="small"
cover={
currentLib?.kind === "image" ? (
<Image
src={`/api/v1/assets/${asset.id}/thumbnail`}
alt={asset.name}
style={{ height: 150, objectFit: "cover" }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lm77niYfliqDovb3lpLHotKU8L3RleHQ+PC9zdmc+"
/>
) : (
<div
style={{
height: 150,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
}}
>
{currentLib?.kind === "video" ? (
<VideoCameraOutlined
style={{ fontSize: 40, color: "#bbb" }}
/>
) : (
<AudioOutlined
style={{ fontSize: 40, color: "#bbb" }}
/>
)}
</div>
)
}
actions={[
<Popconfirm
key="delete"
title="确定删除此素材?"
onConfirm={() => deleteMutation.mutate(asset.id)}
>
<DeleteOutlined />
</Popconfirm>,
]}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h4 style={{ flex: 1, margin: 0 }}>
{kindIcon(lib.kind)} {lib.name}
</h4>
<Popconfirm
title={`确定删除素材库 "${lib.name}"?`}
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteLibrary(lib.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-asset-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除素材库"
>
<Card.Meta
title={
<Text ellipsis style={{ maxWidth: "100%" }}>
{asset.name}
</Text>
}
description={
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>
{asset.file_size
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
: "-"}
</Text>
{asset.status && (
<Tag
color={
asset.status === "completed"
? "success"
: asset.status === "failed"
? "error"
: "processing"
}
style={{ marginTop: 4 }}
>
{asset.status}
</Tag>
)}
</Space>
}
/>
</Card>
</Col>
))}
</Row>
)}
</>
)}
<DeleteOutlined />
</button>
</Popconfirm>
</div>
<span>
{kindLabel(lib.kind)} · {lib.count} 个素材
</span>
</div>
))}
{/* 新建素材库弹窗 */}
<Modal
{/* 新建素材库 */}
<div
className="xx-asset-library-add"
onClick={() => setCreateModalOpen(true)}
>
<PlusOutlined />
新建素材库
</div>
</div>
{/* ─── 右侧:内容区 ─── */}
<div className="xx-assets-content">
{/* 上传区域 */}
<Upload.Dragger
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,audio/*,image/*"
>
<div className="xx-asset-upload-zone">
<p className="xx-asset-upload-icon">
<InboxOutlined />
</p>
<p className="xx-asset-upload-text">
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint">
支持视频、音频、图片,单文件不超过 2GB
</p>
</div>
</Upload.Dragger>
{/* 筛选栏 */}
<div className="xx-assets-filters">
<div className="xx-assets-filters-left">
<Input
placeholder="搜索素材名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={setFilterType}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
<Select
value={filterTime}
onChange={setFilterTime}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]}
/>
</div>
<div className="xx-assets-filters-right">
<Button buttonType="ghost" buttonSize="sm" onClick={selectAll}>
全选
</Button>
<span style={{ fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
共 {filteredAssets.length} 个素材
</span>
</div>
</div>
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
<div className="xx-assets-batch-bar">
<span className="xx-assets-batch-count">
已选 {selectedIds.size} 项
</span>
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
取消选择
</Button>
<Popconfirm
title={`确定删除 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
批量删除
</Button>
</Popconfirm>
</div>
)}
{/* 素材网格 */}
{filteredAssets.length > 0 ? (
<div className="xx-asset-grid">
{filteredAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
selected={selectedIds.has(asset.id)}
onToggle={() => toggleSelect(asset.id)}
onDiagnose={() => handleDiagnose(asset)}
/>
))}
</div>
) : (
<div className="xx-assets-empty">
<div className="xx-assets-empty-icon">
<PictureOutlined />
</div>
<p>暂无素材,请上传或切换素材库</p>
</div>
)}
</div>
</div>
{/* ─── 新建素材库弹窗 ─── */}
<AntModal
title="新建素材库"
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onOk={() =>
createLibMutation.mutate({ name: newLibName, kind: newLibKind })
}
confirmLoading={createLibMutation.isPending}
onOk={handleCreateLibrary}
okText="创建"
cancelText="取消"
destroyOnClose
>
<Space direction="vertical" style={{ width: "100%" }}>
<Input
placeholder="素材库名称"
value={newLibName}
onChange={(e) => setNewLibName(e.target.value)}
/>
<Select
value={newLibKind}
onChange={setNewLibKind}
style={{ width: "100%" }}
options={[
{ value: "video", label: "视频素材" },
{ value: "voice", label: "配音素材" },
{ value: "image", label: "图片素材" },
]}
/>
</Space>
</Modal>
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
<div>
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
名称
</div>
<Input
placeholder="请输入素材库名称"
value={newLibName}
onChange={(e) => setNewLibName(e.target.value)}
maxLength={50}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
类型
</div>
<Select
value={newLibKind}
onChange={(v) => setNewLibKind(v)}
style={{ width: "100%" }}
options={[
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
</div>
</div>
</AntModal>
</div>
);
};
+454
View File
@@ -0,0 +1,454 @@
/**
* 素材库页面 - V21 设计系统样式
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-assets-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
两栏布局
============================================================ */
.xx-assets-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 20px;
align-items: start;
}
/* ============================================================
左侧素材库列表
============================================================ */
.xx-asset-library-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
position: sticky;
top: var(--space-md);
}
.xx-asset-library-item {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
transition: var(--transition-all);
}
.xx-asset-library-item:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-asset-library-item.active {
border-color: var(--primary-color);
background: var(--primary-soft);
box-shadow: var(--shadow-primary);
}
.xx-asset-library-item h4 {
margin: 0 0 4px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-asset-library-item span {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.xx-asset-library-delete {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
flex-shrink: 0;
}
.xx-asset-library-item:hover .xx-asset-library-delete {
opacity: 1;
}
.xx-asset-library-delete:hover {
color: var(--error-color);
background: var(--error-soft);
}
.xx-asset-library-add {
border: 1px dashed var(--border-color);
background: transparent;
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.xx-asset-library-add:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
/* ============================================================
右侧内容区
============================================================ */
.xx-assets-content {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* ============================================================
上传区域
============================================================ */
.xx-asset-upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-2xl) var(--space-xl);
text-align: center;
background: var(--bg-secondary);
cursor: pointer;
transition: var(--transition-all);
}
.xx-asset-upload-zone:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-asset-upload-icon {
font-size: 40px;
margin-bottom: var(--space-sm);
color: var(--primary-color);
}
.xx-asset-upload-text {
font-size: var(--font-size-base) !important;
color: var(--text-primary) !important;
margin: 0 0 var(--space-xs) !important;
font-weight: var(--font-weight-medium);
}
.xx-asset-upload-hint {
font-size: var(--font-size-sm) !important;
color: var(--text-tertiary) !important;
margin: 0 !important;
}
/* ============================================================
筛选栏
============================================================ */
.xx-assets-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.xx-assets-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-assets-filters-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ============================================================
素材网格
============================================================ */
.xx-asset-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
/* ============================================================
素材卡片
============================================================ */
.xx-asset-card {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: var(--transition-all);
}
.xx-asset-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
/* 缩略图 */
.xx-asset-thumb {
aspect-ratio: 9 / 16;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: #fff;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.xx-asset-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-asset-thumb-placeholder {
font-size: var(--font-size-3xl);
opacity: 0.7;
}
/* 播放按钮 */
.xx-asset-play {
position: absolute;
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
color: #fff;
font-size: var(--font-size-md);
transition: var(--transition-all);
}
.xx-asset-card:hover .xx-asset-play {
background: rgba(255, 255, 255, 0.4);
transform: scale(1.1);
}
/* 选中态 */
.xx-asset-card-selected {
border-color: var(--primary-color) !important;
box-shadow: var(--shadow-primary) !important;
}
.xx-asset-check {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: var(--gradient-primary);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
z-index: 2;
}
/* 卡片信息 */
.xx-asset-info {
padding: 12px;
}
.xx-asset-name {
margin: 0 0 6px;
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-asset-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: var(--space-sm);
}
/* 诊断按钮 */
.xx-asset-diagnose-btn {
width: 100%;
padding: 6px;
font-size: var(--font-size-sm);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xs);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.xx-asset-diagnose-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
/* ============================================================
状态标签(StatusPill)
============================================================ */
.xx-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
.xx-status-pill-ok {
background: var(--success-soft);
color: var(--success-color);
}
.xx-status-pill-warn {
background: var(--warning-soft);
color: var(--warning-color);
}
.xx-status-pill-bad {
background: var(--error-soft);
color: var(--error-color);
}
.xx-status-pill-info {
background: var(--info-soft);
color: var(--info-color);
}
/* ============================================================
空状态
============================================================ */
.xx-assets-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--text-secondary);
}
.xx-assets-empty-icon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
/* ============================================================
批量操作栏
============================================================ */
.xx-assets-batch-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
background: var(--primary-soft);
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
}
.xx-assets-batch-count {
font-size: var(--font-size-sm);
color: var(--primary-color);
font-weight: var(--font-weight-semibold);
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1400px) {
.xx-asset-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1200px) {
.xx-assets-layout {
grid-template-columns: 1fr;
}
.xx-asset-library-list {
flex-direction: row;
overflow-x: auto;
position: static;
gap: var(--space-sm);
padding-bottom: var(--space-sm);
}
.xx-asset-library-item {
min-width: 180px;
flex-shrink: 0;
}
.xx-asset-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.xx-assets-page {
padding: var(--space-md);
}
.xx-asset-grid {
grid-template-columns: repeat(2, 1fr);
}
.xx-assets-filters {
flex-direction: column;
align-items: stretch;
}
.xx-assets-filters-left,
.xx-assets-filters-right {
justify-content: flex-start;
}
}
@media (max-width: 480px) {
.xx-assets-page {
padding: var(--space-sm);
}
.xx-asset-grid {
grid-template-columns: 1fr;
}
}