/**
* 素材库页面 — 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 ? (

) : (
{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
{/* 筛选栏 */}
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
已选 {selectedIds.size} 项
}
onClick={() => setTagModalOpen(true)}
>
打标签
}
onClick={() => setClassifyModalOpen(true)}
>
改分类
}
onClick={() => setMarkModalOpen(true)}
>
智能标记
}>
批量删除
)}
{/* 素材网格 */}
{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}
>
类型
{/* ─── 视频/音频播放弹窗 ─── */}
setPlayingAsset(null)}
footer={null}
width={640}
destroyOnClose
>
{playingAsset?.fileUrl ? (
) : (
暂无可播放的文件地址
素材 ID: {playingAsset?.id}
)}
{/* ─── 批量打标签弹窗 ─── */}
{
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
}}
onOk={handleBatchTag}
confirmLoading={batchLoading}
okText="确认打标签"
cancelText="取消"
>
模式:
setTagMode(e.target.value)}>
追加标签
替换全部标签
setBatchTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
style={{ flex: 1 }}
/>
{batchTags.length > 0 && (
{batchTags.map((tag) => (
removeBatchTag(tag)} color="blue">
{tag}
))}
)}
{tagMode === "replace" && batchTags.length > 0 && (
替换模式将清除素材原有全部标签
)}
{/* ─── 批量改分类弹窗 ─── */}
{
setClassifyModalOpen(false)
setBatchCategory("")
}}
onOk={handleBatchClassify}
confirmLoading={batchLoading}
okText="确认修改"
cancelText="取消"
>
将选中的 {selectedIds.size} 个素材统一修改为以下分类:
setBatchCategory(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={[
{ value: "person", label: "人物" },
{ value: "scenic", label: "风景" },
{ value: "product", label: "产品" },
{ value: "food", label: "美食" },
{ value: "animal", label: "动物" },
{ value: "architecture", label: "建筑" },
{ value: "other", label: "其他" },
]}
/>
{/* ─── 批量智能标记弹窗 ─── */}
setMarkModalOpen(false)}
onOk={handleBatchMark}
confirmLoading={batchLoading}
okText="确认标记"
cancelText="取消"
>
将选中的 {selectedIds.size} 个素材标记为:
setBatchSmartView(e.target.value)}
className="xx-batch-mark-options"
>
推荐
质量优良,可直接用于生产
慎用
存在一定问题,需人工审核后再使用
高风险
存在严重问题,不建议使用
{/* ─── 操作结果 Drawer ─── */}
{
setResultDrawerOpen(false)
setOperationResult(null)
}}
width={420}
>
{operationResult && (
总计 {operationResult.total} 个
成功 {operationResult.success_count} 个
{operationResult.failure_count > 0 && (
失败 {operationResult.failure_count} 个
)}
{(operationResult?.succeeded?.length ?? 0) > 0 && (
成功列表
{operationResult?.succeeded?.map((id) => (
{id}
))}
)}
{(operationResult?.failed?.length ?? 0) > 0 && (
失败列表
{operationResult?.failed?.map((id) => (
{id}
))}
)}
)}
)
}
export default AssetLibrary