/** * 素材库页面 — V21 设计系统 * 两栏布局:左侧素材库列表(260px)+ 右侧素材网格 * 使用 useQuery 对接后端真实 API(api/assets.ts) */ import React, { useMemo, useState } from "react" import { Upload, Modal as AntModal, message, Popconfirm, Drawer, Tag, Input as AntInput, Radio, Select as AntSelect, } from "antd" import { PlusOutlined, SearchOutlined, InboxOutlined, VideoCameraOutlined, PictureOutlined, PlayCircleOutlined, CheckOutlined, DeleteOutlined, ExperimentOutlined, LoadingOutlined, ExclamationCircleOutlined, TagsOutlined, FolderOutlined, ThunderboltOutlined, CheckCircleOutlined, CloseCircleOutlined, } from "@ant-design/icons" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { getAssetLibraries, createAssetLibrary, deleteAssetLibrary, getAssets, deleteAsset, uploadAssetDirect, getAssetDiagnosis, batchDeleteAssets, batchTagAssets, batchClassifyAssets, batchMarkAssets, type AssetLibraryItem, type AssetItem as ApiAssetItem, type BatchOperationResult, } 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 做缩略图(是视频文件, 无法渲染) 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 case "image": return } } 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 }) => ( {label} ) /* ============================================================ * SkeletonCard — 骨架屏卡片(素材列表加载时占位) * ============================================================ */ const SkeletonCard: React.FC = () => (
) /* ============================================================ * 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 }) => (
{/* 缩略图区 */}
{asset.thumbUrl ? ( {asset.name} ) : ( {kindIcon(asset.kind)} )} {/* 视频/配音类显示播放按钮 */} {asset.kind === "video" && ( { e.stopPropagation() onPlay() }} > )} {/* 删除按钮 */} { e?.stopPropagation() onDelete() }} onCancel={(e) => e?.stopPropagation()} okText="删除" cancelText="取消" okButtonProps={{ danger: true }} > e.stopPropagation()}> {/* 选中态勾选 */} {selected && ( )}
{/* 信息区 */}

{asset.name}

{asset.duration && {asset.duration}}
) /* ============================================================ * 主组件 * ============================================================ */ const AssetLibrary: React.FC = () => { const queryClient = useQueryClient() /* ── 获取素材库列表 ── */ const { data: apiLibraries = [], isLoading: libLoading } = useQuery({ queryKey: ["asset-libraries"], queryFn: getAssetLibraries, staleTime: 60_000, }) const libraries = useMemo( () => (Array.isArray(apiLibraries) ? apiLibraries : []).map(mapLibrary), [apiLibraries], ) /* ── 当前选中的素材库 ── */ const [activeLibId, setActiveLibId] = useState("") // 当库列表加载完成后,自动选中第一个 const effectiveLibId = activeLibId || libraries[0]?.id || "" /* ── 获取当前库的素材列表 ── */ const { data: apiAssets = [], isLoading: assetsLoading, isError: assetsError, error: assetsErrorObj, refetch: refetchAssets, } = useQuery({ queryKey: ["assets", effectiveLibId], queryFn: () => getAssets(effectiveLibId), enabled: !!effectiveLibId, staleTime: 30_000, }) const assets = useMemo( () => (Array.isArray(apiAssets) ? 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>(new Set()) // 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理 /* 筛选 */ const [searchText, setSearchText] = useState("") const [filterType, setFilterType] = useState("all") const [filterTime, setFilterTime] = useState("all") /* 上传 */ const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState(0) /* 新建素材库 */ const [createModalOpen, setCreateModalOpen] = useState(false) const [newLibName, setNewLibName] = useState("") const [newLibKind, setNewLibKind] = useState("video") /* 视频播放 */ const [playingAsset, setPlayingAsset] = useState(null) /* 诊断中状态 — 记录正在诊断的素材 ID */ const [diagnosingId, setDiagnosingId] = useState(null) /* ── 批量操作弹窗状态 ── */ const [tagModalOpen, setTagModalOpen] = useState(false) const [classifyModalOpen, setClassifyModalOpen] = useState(false) const [markModalOpen, setMarkModalOpen] = useState(false) const [resultDrawerOpen, setResultDrawerOpen] = useState(false) /* 批量打标签 */ const [batchTagInput, setBatchTagInput] = useState("") const [batchTags, setBatchTags] = useState([]) const [tagMode, setTagMode] = useState<"add" | "replace">("add") /* 批量改分类 */ const [batchCategory, setBatchCategory] = useState("") /* 批量智能标记 */ const [batchSmartView, setBatchSmartView] = useState<"recommended" | "caution" | "high_risk">( "recommended", ) /* 操作结果 */ const [operationResult, setOperationResult] = useState(null) const [operationTitle, setOperationTitle] = useState("") /* 批量操作 loading */ const [batchLoading, setBatchLoading] = useState(false) /* 派生数据 */ 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) setBatchLoading(true) try { const result = await batchDeleteAssets(ids) setOperationResult(result) setOperationTitle("批量删除") setResultDrawerOpen(true) queryClient.invalidateQueries({ queryKey: ["assets"] }) queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) setSelectedIds(new Set()) if (result.failure_count === 0) { message.success(`成功删除 ${result.success_count} 个素材`) } else { message.warning( `删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, ) } } catch { message.error("批量删除失败,请重试") } finally { setBatchLoading(false) } } /* 批量打标签 */ const handleBatchTag = async () => { if (batchTags.length === 0) { message.warning("请至少输入一个标签") return } const ids = Array.from(selectedIds) setBatchLoading(true) try { const result = await batchTagAssets({ asset_ids: ids, tags: batchTags, mode: tagMode, }) setOperationResult(result) setOperationTitle("批量打标签") setResultDrawerOpen(true) setTagModalOpen(false) setBatchTags([]) setBatchTagInput("") setTagMode("add") queryClient.invalidateQueries({ queryKey: ["assets"] }) setSelectedIds(new Set()) if (result.failure_count === 0) { message.success(`成功为 ${result.success_count} 个素材打标签`) } else { message.warning( `打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, ) } } catch { message.error("批量打标签失败,请重试") } finally { setBatchLoading(false) } } /* 批量改分类 */ const handleBatchClassify = async () => { if (!batchCategory) { message.warning("请选择分类") return } const ids = Array.from(selectedIds) setBatchLoading(true) try { const result = await batchClassifyAssets({ asset_ids: ids, category: batchCategory, }) setOperationResult(result) setOperationTitle("批量改分类") setResultDrawerOpen(true) setClassifyModalOpen(false) setBatchCategory("") queryClient.invalidateQueries({ queryKey: ["assets"] }) setSelectedIds(new Set()) if (result.failure_count === 0) { message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`) } else { message.warning( `改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, ) } } catch { message.error("批量改分类失败,请重试") } finally { setBatchLoading(false) } } /* 批量智能标记 */ const handleBatchMark = async () => { const ids = Array.from(selectedIds) setBatchLoading(true) try { const result = await batchMarkAssets({ asset_ids: ids, smart_view: batchSmartView, }) setOperationResult(result) setOperationTitle("批量智能标记") setResultDrawerOpen(true) setMarkModalOpen(false) queryClient.invalidateQueries({ queryKey: ["assets"] }) setSelectedIds(new Set()) const labelMap = { recommended: "推荐", caution: "慎用", high_risk: "高风险", } if (result.failure_count === 0) { message.success( `成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`, ) } else { message.warning( `智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, ) } } catch { message.error("批量智能标记失败,请重试") } finally { setBatchLoading(false) } } /* 标签输入处理 */ const handleTagInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && batchTagInput.trim()) { e.preventDefault() const tag = batchTagInput.trim() if (!batchTags.includes(tag)) { setBatchTags([...batchTags, tag]) } setBatchTagInput("") } } const removeBatchTag = (tag: string) => { setBatchTags(batchTags.filter((t) => t !== tag)) } // ── Loading 状态 ── if (libLoading) { return (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) } return (
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
{/* 背景圆环 */} {/* 进度圆弧 */}
{uploadProgress}% 上传中…
{/* 两栏布局 */}
{/* ─── 左侧:素材库列表 ─── */}
{libraries.map((lib) => (
setActiveLibId(lib.id)} >

{kindIcon(lib.kind)} {lib.name}

{ e?.stopPropagation() handleDeleteLibrary(lib.id) }} onCancel={(e) => e?.stopPropagation()} okText="删除" cancelText="取消" >
{kindLabel(lib.kind)} · {lib.count} 个素材
))} {/* 新建素材库 */}
setCreateModalOpen(true)}> 新建素材库
{/* ─── 右侧:内容区 ─── */}
{/* 上传区域 */} { // 同步返回 false 阻止 antd 默认上传行为 // 异步上传由 handleUpload 处理 handleUpload(file as File) return false }} showUploadList={false} multiple accept="video/*,image/*" >

{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}

支持视频、图片,单文件不超过 2GB

{/* 筛选栏 */}
} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 220 }} />
共 {filteredAssets.length} 个素材
{/* 批量操作栏 */} {selectedIds.size > 0 && (
已选 {selectedIds.size} 项
)} {/* 素材网格 */} {assetsLoading ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : assetsError ? (

{assetsErrorObj?.message || "加载失败"}

) : filteredAssets.length > 0 ? (
{filteredAssets.map((asset) => ( toggleSelect(asset.id)} onDiagnose={() => handleDiagnose(asset)} onPlay={() => setPlayingAsset(asset)} onDelete={() => handleSingleDelete(asset.id)} /> ))}
) : (

暂无素材,请上传或切换素材库

)}
{/* ─── 新建素材库弹窗 ─── */} setCreateModalOpen(false)} onOk={handleCreateLibrary} okText="创建" cancelText="取消" destroyOnClose confirmLoading={createLibMutation.isPending} >
名称
setNewLibName(e.target.value)} maxLength={50} />
类型