0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
1225 lines
39 KiB
TypeScript
Executable File
1225 lines
39 KiB
TypeScript
Executable File
/**
|
||
* 素材库页面 — 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 做缩略图(是视频文件,<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(
|
||
() => (Array.isArray(apiLibraries) ? 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(
|
||
() => (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<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 [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<string[]>([])
|
||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||
|
||
/* 批量改分类 */
|
||
const [batchCategory, setBatchCategory] = useState("")
|
||
|
||
/* 批量智能标记 */
|
||
const [batchSmartView, setBatchSmartView] = useState<"recommended" | "caution" | "high_risk">(
|
||
"recommended",
|
||
)
|
||
|
||
/* 操作结果 */
|
||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(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 (
|
||
<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>
|
||
<Button
|
||
buttonType="ghost"
|
||
buttonSize="sm"
|
||
icon={<TagsOutlined />}
|
||
onClick={() => setTagModalOpen(true)}
|
||
>
|
||
打标签
|
||
</Button>
|
||
<Button
|
||
buttonType="ghost"
|
||
buttonSize="sm"
|
||
icon={<FolderOutlined />}
|
||
onClick={() => setClassifyModalOpen(true)}
|
||
>
|
||
改分类
|
||
</Button>
|
||
<Button
|
||
buttonType="ghost"
|
||
buttonSize="sm"
|
||
icon={<ThunderboltOutlined />}
|
||
onClick={() => setMarkModalOpen(true)}
|
||
>
|
||
智能标记
|
||
</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>
|
||
|
||
{/* ─── 批量打标签弹窗 ─── */}
|
||
<AntModal
|
||
title={`批量打标签(${selectedIds.size} 个素材)`}
|
||
open={tagModalOpen}
|
||
onCancel={() => {
|
||
setTagModalOpen(false)
|
||
setBatchTags([])
|
||
setBatchTagInput("")
|
||
}}
|
||
onOk={handleBatchTag}
|
||
confirmLoading={batchLoading}
|
||
okText="确认打标签"
|
||
cancelText="取消"
|
||
>
|
||
<div className="xx-batch-tag-modal">
|
||
<div className="xx-batch-tag-mode">
|
||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||
<Radio.Group value={tagMode} onChange={(e) => setTagMode(e.target.value)}>
|
||
<Radio value="add">追加标签</Radio>
|
||
<Radio value="replace">替换全部标签</Radio>
|
||
</Radio.Group>
|
||
</div>
|
||
<div className="xx-batch-tag-input-row">
|
||
<AntInput
|
||
placeholder="输入标签后按 Enter 添加"
|
||
value={batchTagInput}
|
||
onChange={(e) => setBatchTagInput(e.target.value)}
|
||
onKeyDown={handleTagInputKeyDown}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
</div>
|
||
{batchTags.length > 0 && (
|
||
<div className="xx-batch-tag-list">
|
||
{batchTags.map((tag) => (
|
||
<Tag key={tag} closable onClose={() => removeBatchTag(tag)} color="blue">
|
||
{tag}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
)}
|
||
{tagMode === "replace" && batchTags.length > 0 && (
|
||
<div className="xx-batch-tag-warning">
|
||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||
</div>
|
||
)}
|
||
</div>
|
||
</AntModal>
|
||
|
||
{/* ─── 批量改分类弹窗 ─── */}
|
||
<AntModal
|
||
title={`批量改分类(${selectedIds.size} 个素材)`}
|
||
open={classifyModalOpen}
|
||
onCancel={() => {
|
||
setClassifyModalOpen(false)
|
||
setBatchCategory("")
|
||
}}
|
||
onOk={handleBatchClassify}
|
||
confirmLoading={batchLoading}
|
||
okText="确认修改"
|
||
cancelText="取消"
|
||
>
|
||
<div className="xx-batch-classify-modal">
|
||
<p className="xx-batch-classify-hint">
|
||
将选中的 {selectedIds.size} 个素材统一修改为以下分类:
|
||
</p>
|
||
<AntSelect
|
||
value={batchCategory || undefined}
|
||
onChange={(v) => 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: "其他" },
|
||
]}
|
||
/>
|
||
</div>
|
||
</AntModal>
|
||
|
||
{/* ─── 批量智能标记弹窗 ─── */}
|
||
<AntModal
|
||
title={`批量智能标记(${selectedIds.size} 个素材)`}
|
||
open={markModalOpen}
|
||
onCancel={() => setMarkModalOpen(false)}
|
||
onOk={handleBatchMark}
|
||
confirmLoading={batchLoading}
|
||
okText="确认标记"
|
||
cancelText="取消"
|
||
>
|
||
<div className="xx-batch-mark-modal">
|
||
<p className="xx-batch-mark-hint">将选中的 {selectedIds.size} 个素材标记为:</p>
|
||
<Radio.Group
|
||
value={batchSmartView}
|
||
onChange={(e) => setBatchSmartView(e.target.value)}
|
||
className="xx-batch-mark-options"
|
||
>
|
||
<div className="xx-batch-mark-option">
|
||
<Radio value="recommended">
|
||
<Tag color="success">推荐</Tag>
|
||
<span className="xx-batch-mark-desc">质量优良,可直接用于生产</span>
|
||
</Radio>
|
||
</div>
|
||
<div className="xx-batch-mark-option">
|
||
<Radio value="caution">
|
||
<Tag color="warning">慎用</Tag>
|
||
<span className="xx-batch-mark-desc">存在一定问题,需人工审核后再使用</span>
|
||
</Radio>
|
||
</div>
|
||
<div className="xx-batch-mark-option">
|
||
<Radio value="high_risk">
|
||
<Tag color="error">高风险</Tag>
|
||
<span className="xx-batch-mark-desc">存在严重问题,不建议使用</span>
|
||
</Radio>
|
||
</div>
|
||
</Radio.Group>
|
||
</div>
|
||
</AntModal>
|
||
|
||
{/* ─── 操作结果 Drawer ─── */}
|
||
<Drawer
|
||
title={`${operationTitle} — 操作结果`}
|
||
open={resultDrawerOpen}
|
||
onClose={() => {
|
||
setResultDrawerOpen(false)
|
||
setOperationResult(null)
|
||
}}
|
||
width={420}
|
||
>
|
||
{operationResult && (
|
||
<div className="xx-batch-result">
|
||
<div className="xx-batch-result-summary">
|
||
<div className="xx-batch-result-stat">
|
||
<span className="xx-batch-result-total">总计 {operationResult.total} 个</span>
|
||
</div>
|
||
<div className="xx-batch-result-stat success">
|
||
<CheckCircleOutlined />
|
||
<span>成功 {operationResult.success_count} 个</span>
|
||
</div>
|
||
{operationResult.failure_count > 0 && (
|
||
<div className="xx-batch-result-stat fail">
|
||
<CloseCircleOutlined />
|
||
<span>失败 {operationResult.failure_count} 个</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(operationResult?.succeeded?.length ?? 0) > 0 && (
|
||
<div className="xx-batch-result-section">
|
||
<h4 className="xx-batch-result-section-title success">
|
||
<CheckCircleOutlined /> 成功列表
|
||
</h4>
|
||
<div className="xx-batch-result-ids">
|
||
{operationResult?.succeeded?.map((id) => (
|
||
<div key={id} className="xx-batch-result-id">
|
||
{id}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(operationResult?.failed?.length ?? 0) > 0 && (
|
||
<div className="xx-batch-result-section">
|
||
<h4 className="xx-batch-result-section-title fail">
|
||
<CloseCircleOutlined /> 失败列表
|
||
</h4>
|
||
<div className="xx-batch-result-ids">
|
||
{operationResult?.failed?.map((id) => (
|
||
<div key={id} className="xx-batch-result-id fail">
|
||
{id}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Drawer>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default AssetLibrary
|