4a6db70941
CI/CD Pipeline / Staging E2E Tests (push) Failing after 20h28m58s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 20h32m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 20h33m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 20h33m25s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1679h8m2s
CI/CD Pipeline / Deploy Production (push) Failing after 1679h8m5s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1679h8m7s
当素材 status 为 ready 时,跳过 classification_status 检查, 直接根据 quality_score 显示状态(待诊断/合格/待优化/不合格)。 修复了 P0-4 验收中发现的:API 返回 ready 但 UI 显示处理中的 bug。
906 lines
27 KiB
TypeScript
906 lines
27 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,
|
||
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" | "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";
|
||
return "image";
|
||
};
|
||
|
||
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
|
||
const inferStatus = (
|
||
score?: number,
|
||
classificationStatus?: string,
|
||
assetStatus?: string,
|
||
): { status: StatusType; label: string } => {
|
||
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
|
||
if (assetStatus === "ready") {
|
||
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: "不合格" };
|
||
}
|
||
// 素材未就绪:classification 正在处理中
|
||
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 === "voice" ? "video" : 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,
|
||
item.status ?? undefined,
|
||
);
|
||
const metadata = item.metadata || {};
|
||
const kind = inferKind(item.mime_type || "");
|
||
return {
|
||
id: item.id,
|
||
name: item.name,
|
||
kind,
|
||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||
thumbUrl:
|
||
(item.thumbnail_url as string | undefined) ||
|
||
(metadata.thumbnail_url as string | undefined) ||
|
||
(kind !== "video" ? (item.file_url as string | undefined) : 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 "image":
|
||
return <PictureOutlined />;
|
||
}
|
||
};
|
||
|
||
const kindLabel = (kind: AssetKind) => {
|
||
switch (kind) {
|
||
case "video":
|
||
return "视频";
|
||
case "image":
|
||
return "图片";
|
||
}
|
||
};
|
||
|
||
/** 根据素材类型返回渐变背景 */
|
||
const thumbGradient = (kind: AssetKind): string => {
|
||
switch (kind) {
|
||
case "video":
|
||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 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;
|
||
onDelete: () => void;
|
||
}> = ({
|
||
asset,
|
||
selected,
|
||
diagnosing,
|
||
onToggle,
|
||
onDiagnose,
|
||
onPlay,
|
||
onDelete,
|
||
}) => (
|
||
<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" && (
|
||
<span
|
||
className="xx-asset-play"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onPlay();
|
||
}}
|
||
>
|
||
<PlayCircleOutlined />
|
||
</span>
|
||
)}
|
||
|
||
{/* 删除按钮 */}
|
||
<Popconfirm
|
||
title="确认删除"
|
||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||
onConfirm={(e) => {
|
||
e?.stopPropagation();
|
||
onDelete();
|
||
}}
|
||
onCancel={(e) => e?.stopPropagation()}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
okButtonProps={{ danger: true }}
|
||
>
|
||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||
<DeleteOutlined />
|
||
</span>
|
||
</Popconfirm>
|
||
|
||
{/* 选中态勾选 */}
|
||
{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());
|
||
|
||
// 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理
|
||
|
||
/* 筛选 */
|
||
const [searchText, setSearchText] = useState("");
|
||
const [filterType, setFilterType] = useState<string>("all");
|
||
const [filterTime, setFilterTime] = useState<string>("all");
|
||
|
||
/* 上传 */
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState(0);
|
||
|
||
/* 新建素材库 */
|
||
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;
|
||
}
|
||
if (!effectiveLibId) {
|
||
message.warning("请先选择或创建一个素材库");
|
||
return;
|
||
}
|
||
|
||
setUploading(true);
|
||
setUploadProgress(0);
|
||
try {
|
||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||
message.info(`大文件 "${file.name}" 将使用直传上传`);
|
||
}
|
||
await uploadAssetDirect({
|
||
file,
|
||
library_id: effectiveLibId,
|
||
onProgress: (pct) => setUploadProgress(pct),
|
||
});
|
||
message.success(`"${file.name}" 上传成功`);
|
||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||
} catch (err: unknown) {
|
||
const detail = err instanceof Error ? err.message : "";
|
||
console.error("[handleUpload] 上传失败:", err);
|
||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
|
||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
} finally {
|
||
setUploading(false);
|
||
setUploadProgress(0);
|
||
}
|
||
};
|
||
|
||
/* 新建素材库 */
|
||
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 handleSingleDelete = async (assetId: string) => {
|
||
try {
|
||
await deleteAsset(assetId);
|
||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||
// 从选中集合中移除
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete(assetId);
|
||
return next;
|
||
});
|
||
message.success("素材已删除");
|
||
} catch {
|
||
message.error("删除失败,请重试");
|
||
}
|
||
};
|
||
|
||
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">
|
||
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
|
||
<AntModal
|
||
open={uploading}
|
||
footer={null}
|
||
closable={false}
|
||
centered
|
||
width={260}
|
||
maskClosable={false}
|
||
className="xx-upload-progress-modal"
|
||
>
|
||
<div className="xx-upload-progress-body">
|
||
<svg
|
||
className="xx-upload-progress-ring"
|
||
viewBox="0 0 120 120"
|
||
width={120}
|
||
height={120}
|
||
>
|
||
{/* 背景圆环 */}
|
||
<circle
|
||
cx="60"
|
||
cy="60"
|
||
r="52"
|
||
fill="none"
|
||
stroke="var(--border-primary, #e5e7eb)"
|
||
strokeWidth="8"
|
||
/>
|
||
{/* 进度圆弧 */}
|
||
<circle
|
||
cx="60"
|
||
cy="60"
|
||
r="52"
|
||
fill="none"
|
||
stroke="var(--primary-color, #6366f1)"
|
||
strokeWidth="8"
|
||
strokeLinecap="round"
|
||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
|
||
transform="rotate(-90 60 60)"
|
||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||
/>
|
||
</svg>
|
||
<div className="xx-upload-progress-text">
|
||
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
|
||
<span className="xx-upload-progress-label">上传中…</span>
|
||
</div>
|
||
</div>
|
||
</AntModal>
|
||
|
||
{/* 两栏布局 */}
|
||
<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={(file) => {
|
||
// 同步返回 false 阻止 antd 默认上传行为
|
||
// 异步上传由 handleUpload 处理
|
||
handleUpload(file as File);
|
||
return false;
|
||
}}
|
||
showUploadList={false}
|
||
multiple
|
||
accept="video/*,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: "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)}
|
||
onDelete={() => handleSingleDelete(asset.id)}
|
||
/>
|
||
))}
|
||
</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: "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;
|