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