b0e9266571
Deploy / Staging E2E Tests (push) Failing after 99h8m52s
Deploy / Deploy Staging (push) Failing after 99h11m47s
CI/CD Pipeline / Frontend Lint (push) Failing after 99h13m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 99h13m16s
Deploy / Production Browser E2E (push) Failing after 1757h38m19s
Deploy / Build Production Runtime Images (push) Failing after 1757h45m12s
Deploy / Deploy Production (push) Failing after 1758h10m38s
- assets.css: 补全 6 个组件 :active 状态 + :disabled (diagnose-btn) - templates.css: 补全 6 个组件 :active 状态 - AssetLibrary.tsx: emoji 替换为 Ant Design 图标,内联样式迁移到 CSS 类 - TemplateLibrary.tsx: emoji 替换为 Ant Design 图标,预览弹窗内联样式迁移 - 新增骨架屏网格、表单、视频播放器等迁移用 CSS 类 - 统一使用 V21 设计系统 CSS 变量
773 lines
24 KiB
TypeScript
773 lines
24 KiB
TypeScript
/**
|
||
* 素材库页面 — V21 设计系统
|
||
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
|
||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||
*/
|
||
import React, { useMemo, useState } from "react";
|
||
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
|
||
import {
|
||
PlusOutlined,
|
||
SearchOutlined,
|
||
InboxOutlined,
|
||
VideoCameraOutlined,
|
||
SoundOutlined,
|
||
PictureOutlined,
|
||
PlayCircleOutlined,
|
||
CheckOutlined,
|
||
DeleteOutlined,
|
||
ExperimentOutlined,
|
||
LoadingOutlined,
|
||
ExclamationCircleOutlined,
|
||
} from "@ant-design/icons";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
getAssetLibraries,
|
||
createAssetLibrary,
|
||
deleteAssetLibrary,
|
||
getAssets,
|
||
deleteAsset,
|
||
uploadAssetDirect,
|
||
getAssetDiagnosis,
|
||
type AssetLibraryItem,
|
||
type AssetItem as ApiAssetItem,
|
||
} from "@/api/assets";
|
||
import { Button, Input, Select } from "@/components/ui";
|
||
import "./assets.css";
|
||
|
||
/* ============================================================
|
||
* 类型
|
||
* ============================================================ */
|
||
type AssetKind = "video" | "voice" | "image";
|
||
type StatusType = "ok" | "warn" | "bad" | "info";
|
||
|
||
interface LibraryItem {
|
||
id: string;
|
||
name: string;
|
||
kind: AssetKind;
|
||
count: number;
|
||
}
|
||
|
||
interface AssetItem {
|
||
id: string;
|
||
name: string;
|
||
kind: AssetKind;
|
||
thumbUrl?: string;
|
||
fileUrl?: string;
|
||
status: StatusType;
|
||
statusLabel: string;
|
||
duration?: string;
|
||
size: number;
|
||
createdAt: string;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 映射:后端 → 前端
|
||
* ============================================================ */
|
||
|
||
/** 根据 mime_type 推断前端 AssetKind */
|
||
const inferKind = (mimeType: string): AssetKind => {
|
||
if (mimeType.startsWith("video/")) return "video";
|
||
if (mimeType.startsWith("audio/")) return "voice";
|
||
return "image";
|
||
};
|
||
|
||
/** 根据 quality_score 推断前端状态 */
|
||
const inferStatus = (
|
||
score?: number,
|
||
classificationStatus?: string,
|
||
): { status: StatusType; label: string } => {
|
||
if (
|
||
classificationStatus === "processing" ||
|
||
classificationStatus === "pending"
|
||
) {
|
||
return { status: "info", label: "处理中" };
|
||
}
|
||
if (score == null) return { status: "info", label: "待诊断" };
|
||
if (score >= 70) return { status: "ok", label: "合格" };
|
||
if (score >= 40) return { status: "warn", label: "待优化" };
|
||
return { status: "bad", label: "不合格" };
|
||
};
|
||
|
||
/** 格式化时长(秒 → mm:ss) */
|
||
const formatDuration = (seconds: number): string => {
|
||
const m = Math.floor(seconds / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||
};
|
||
|
||
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
|
||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
kind: item.kind || inferKind("video"),
|
||
count: item.asset_count ?? 0,
|
||
});
|
||
|
||
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
|
||
const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||
const { status, label } = inferStatus(
|
||
item.quality_score ?? undefined,
|
||
item.classification_status ?? undefined,
|
||
);
|
||
const metadata = item.metadata || {};
|
||
return {
|
||
id: item.id,
|
||
name: item.name,
|
||
kind: inferKind(item.mime_type || ""),
|
||
thumbUrl:
|
||
(item.thumbnail_url as string | undefined) ||
|
||
(item.file_url as string | undefined) ||
|
||
(metadata.thumbnail_url as string | undefined),
|
||
fileUrl:
|
||
(item.file_url as string | undefined) ||
|
||
(metadata.file_url as string | undefined),
|
||
status,
|
||
statusLabel: label,
|
||
duration:
|
||
metadata.duration != null
|
||
? formatDuration(metadata.duration as number)
|
||
: undefined,
|
||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||
createdAt: item.created_at
|
||
? new Date(item.created_at).toISOString().slice(0, 10)
|
||
: "—",
|
||
};
|
||
};
|
||
|
||
/* ============================================================
|
||
* 常量
|
||
* ============================================================ */
|
||
const MAX_FILE_SIZE = 2048 * 1024 * 1024;
|
||
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
|
||
|
||
/* ============================================================
|
||
* 工具函数
|
||
* ============================================================ */
|
||
const kindIcon = (kind: AssetKind) => {
|
||
switch (kind) {
|
||
case "video":
|
||
return <VideoCameraOutlined />;
|
||
case "voice":
|
||
return <SoundOutlined />;
|
||
case "image":
|
||
return <PictureOutlined />;
|
||
}
|
||
};
|
||
|
||
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>
|
||
);
|
||
|
||
/* ============================================================
|
||
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
|
||
* ============================================================ */
|
||
const SkeletonCard: React.FC = () => (
|
||
<div className="xx-asset-card xx-asset-skeleton">
|
||
<div className="xx-asset-thumb xx-skeleton-pulse" />
|
||
<div className="xx-asset-info">
|
||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
|
||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
|
||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "100%", height: 28, marginTop: 8, borderRadius: "var(--radius-xs)" }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
/* ============================================================
|
||
* AssetCard 组件
|
||
* ============================================================ */
|
||
const AssetCard: React.FC<{
|
||
asset: AssetItem;
|
||
selected: boolean;
|
||
diagnosing?: boolean;
|
||
onToggle: () => void;
|
||
onDiagnose: () => void;
|
||
onPlay: () => void;
|
||
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
|
||
<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"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onPlay();
|
||
}}
|
||
>
|
||
<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${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||
disabled={diagnosing}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDiagnose();
|
||
}}
|
||
>
|
||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||
{diagnosing ? "诊断中..." : "诊断"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
/* ============================================================
|
||
* 主组件
|
||
* ============================================================ */
|
||
const AssetLibrary: React.FC = () => {
|
||
const queryClient = useQueryClient();
|
||
|
||
/* ── 获取素材库列表 ── */
|
||
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<
|
||
AssetLibraryItem[],
|
||
Error
|
||
>({
|
||
queryKey: ["asset-libraries"],
|
||
queryFn: getAssetLibraries,
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const libraries = useMemo(() => apiLibraries.map(mapLibrary), [apiLibraries]);
|
||
|
||
/* ── 当前选中的素材库 ── */
|
||
const [activeLibId, setActiveLibId] = useState<string>("");
|
||
|
||
// 当库列表加载完成后,自动选中第一个
|
||
const effectiveLibId = activeLibId || libraries[0]?.id || "";
|
||
|
||
/* ── 获取当前库的素材列表 ── */
|
||
const {
|
||
data: apiAssets = [],
|
||
isLoading: assetsLoading,
|
||
isError: assetsError,
|
||
error: assetsErrorObj,
|
||
refetch: refetchAssets,
|
||
} = useQuery<ApiAssetItem[], Error>({
|
||
queryKey: ["assets", effectiveLibId],
|
||
queryFn: () => getAssets(effectiveLibId),
|
||
enabled: !!effectiveLibId,
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const assets = useMemo(() => apiAssets.map(mapAsset), [apiAssets]);
|
||
|
||
/* ── Mutations ── */
|
||
const createLibMutation = useMutation({
|
||
mutationFn: createAssetLibrary,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||
message.success("素材库创建成功");
|
||
},
|
||
onError: () => {
|
||
message.error("创建素材库失败");
|
||
},
|
||
});
|
||
|
||
const deleteLibMutation = useMutation({
|
||
mutationFn: deleteAssetLibrary,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||
message.success("素材库已删除");
|
||
},
|
||
onError: () => {
|
||
message.error("删除素材库失败");
|
||
},
|
||
});
|
||
|
||
/* 状态 */
|
||
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 [createModalOpen, setCreateModalOpen] = useState(false);
|
||
const [newLibName, setNewLibName] = useState("");
|
||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video");
|
||
|
||
/* 视频播放 */
|
||
const [playingAsset, setPlayingAsset] = useState<AssetItem | null>(null);
|
||
|
||
/* 诊断中状态 — 记录正在诊断的素材 ID */
|
||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
|
||
|
||
/* 派生数据 */
|
||
const filteredAssets = useMemo(() => {
|
||
let list = assets;
|
||
|
||
/* 按素材库类型过滤(如果筛选类型不是 all) */
|
||
if (filterType !== "all") {
|
||
list = list.filter((a) => a.kind === filterType);
|
||
}
|
||
|
||
/* 按时间筛选 */
|
||
if (filterTime !== "all") {
|
||
const now = new Date();
|
||
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, 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());
|
||
};
|
||
|
||
/* 上传 — 调用真实 API */
|
||
const handleUpload = async (file: File) => {
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
message.error(`文件 "${file.name}" 超过 2GB 限制`);
|
||
return false;
|
||
}
|
||
if (!effectiveLibId) {
|
||
message.warning("请先选择或创建一个素材库");
|
||
return false;
|
||
}
|
||
|
||
setUploading(true);
|
||
try {
|
||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||
message.info(`大文件 "${file.name}" 将使用直传上传`);
|
||
}
|
||
await uploadAssetDirect({ file, library_id: effectiveLibId });
|
||
message.success(`"${file.name}" 上传成功`);
|
||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||
} catch {
|
||
message.error(`"${file.name}" 上传失败`);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
/* 新建素材库 */
|
||
const handleCreateLibrary = async () => {
|
||
if (!newLibName.trim()) {
|
||
message.warning("请输入素材库名称");
|
||
return;
|
||
}
|
||
try {
|
||
const newLib = await createLibMutation.mutateAsync({
|
||
name: newLibName.trim(),
|
||
kind: newLibKind,
|
||
});
|
||
setActiveLibId(newLib.id);
|
||
setCreateModalOpen(false);
|
||
setNewLibName("");
|
||
setNewLibKind("video");
|
||
} catch {
|
||
// error handled in mutation
|
||
}
|
||
};
|
||
|
||
/* 删除素材库 */
|
||
const handleDeleteLibrary = async (id: string) => {
|
||
try {
|
||
await deleteLibMutation.mutateAsync(id);
|
||
if (effectiveLibId === id) {
|
||
const remaining = libraries.filter((l) => l.id !== id);
|
||
if (remaining.length > 0) setActiveLibId(remaining[0].id);
|
||
else setActiveLibId("");
|
||
}
|
||
} catch {
|
||
// error handled in mutation
|
||
}
|
||
};
|
||
|
||
/* 诊断 — 调用真实 API,带 loading 状态 */
|
||
const handleDiagnose = async (asset: AssetItem) => {
|
||
setDiagnosingId(asset.id);
|
||
try {
|
||
const result = await getAssetDiagnosis(asset.id);
|
||
const score = result.readiness_score ?? "-";
|
||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`);
|
||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||
} catch {
|
||
message.error(`"${asset.name}" 诊断失败`);
|
||
} finally {
|
||
setDiagnosingId(null);
|
||
}
|
||
};
|
||
|
||
/* 批量删除 */
|
||
const handleBatchDelete = async () => {
|
||
const ids = Array.from(selectedIds);
|
||
let successCount = 0;
|
||
for (const id of ids) {
|
||
try {
|
||
await deleteAsset(id);
|
||
successCount++;
|
||
} catch {
|
||
// 忽略单个失败
|
||
}
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||
setSelectedIds(new Set());
|
||
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
|
||
};
|
||
|
||
// ── Loading 状态 ──
|
||
if (libLoading) {
|
||
return (
|
||
<div className="xx-assets-page">
|
||
<div className="xx-assets-skeleton-grid">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<SkeletonCard key={i} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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 === effectiveLibId ? " active" : ""}`}
|
||
onClick={() => setActiveLibId(lib.id)}
|
||
>
|
||
<div className="xx-asset-library-header">
|
||
<h4>
|
||
{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="删除素材库"
|
||
>
|
||
<DeleteOutlined />
|
||
</button>
|
||
</Popconfirm>
|
||
</div>
|
||
<span>
|
||
{kindLabel(lib.kind)} · {lib.count} 个素材
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* 新建素材库 */}
|
||
<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 className="xx-assets-filter-count">
|
||
共 {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>
|
||
)}
|
||
|
||
{/* 素材网格 */}
|
||
{assetsLoading ? (
|
||
<div className="xx-asset-grid">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<SkeletonCard key={i} />
|
||
))}
|
||
</div>
|
||
) : assetsError ? (
|
||
<div className="xx-assets-empty">
|
||
<div className="xx-assets-empty-icon"><ExclamationCircleOutlined /></div>
|
||
<p className="xx-assets-empty-title">{assetsErrorObj?.message || "加载失败"}</p>
|
||
<Button
|
||
buttonType="primary"
|
||
buttonSize="sm"
|
||
onClick={() => refetchAssets()}
|
||
>
|
||
重新加载
|
||
</Button>
|
||
</div>
|
||
) : filteredAssets.length > 0 ? (
|
||
<div className="xx-asset-grid">
|
||
{filteredAssets.map((asset) => (
|
||
<AssetCard
|
||
key={asset.id}
|
||
asset={asset}
|
||
selected={selectedIds.has(asset.id)}
|
||
diagnosing={diagnosingId === asset.id}
|
||
onToggle={() => toggleSelect(asset.id)}
|
||
onDiagnose={() => handleDiagnose(asset)}
|
||
onPlay={() => setPlayingAsset(asset)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="xx-assets-empty">
|
||
<div className="xx-assets-empty-icon">
|
||
<PictureOutlined />
|
||
</div>
|
||
<p className="xx-assets-empty-title">暂无素材,请上传或切换素材库</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─── 新建素材库弹窗 ─── */}
|
||
<AntModal
|
||
title="新建素材库"
|
||
open={createModalOpen}
|
||
onCancel={() => setCreateModalOpen(false)}
|
||
onOk={handleCreateLibrary}
|
||
okText="创建"
|
||
cancelText="取消"
|
||
destroyOnClose
|
||
confirmLoading={createLibMutation.isPending}
|
||
>
|
||
<div className="xx-asset-form-body">
|
||
<div>
|
||
<div className="xx-asset-form-label">名称</div>
|
||
<Input
|
||
placeholder="请输入素材库名称"
|
||
value={newLibName}
|
||
onChange={(e) => setNewLibName(e.target.value)}
|
||
maxLength={50}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="xx-asset-form-label">类型</div>
|
||
<Select
|
||
value={newLibKind}
|
||
onChange={(v) => setNewLibKind(v)}
|
||
style={{ width: "100%" }}
|
||
options={[
|
||
{ value: "video", label: "视频" },
|
||
{ value: "voice", label: "配音" },
|
||
{ value: "image", label: "图片" },
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</AntModal>
|
||
|
||
{/* ─── 视频/音频播放弹窗 ─── */}
|
||
<AntModal
|
||
title={playingAsset?.name ?? "播放"}
|
||
open={!!playingAsset}
|
||
onCancel={() => setPlayingAsset(null)}
|
||
footer={null}
|
||
width={640}
|
||
destroyOnClose
|
||
>
|
||
{playingAsset?.fileUrl ? (
|
||
<video
|
||
src={playingAsset.fileUrl}
|
||
controls
|
||
autoPlay
|
||
className="xx-asset-video-player"
|
||
/>
|
||
) : (
|
||
<div className="xx-asset-empty-fallback">
|
||
<p>暂无可播放的文件地址</p>
|
||
<p className="xx-asset-empty-fallback-id">
|
||
素材 ID: {playingAsset?.id}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</AntModal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AssetLibrary;
|