Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41eafee545 | |||
| d857298737 |
@@ -1,443 +0,0 @@
|
||||
/**
|
||||
* 素材相关 API
|
||||
* Phase 1 重构:去掉 project_id,素材直接归属用户
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import { getOrCreateDefaultProject } from "./projects"
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number
|
||||
/** 宽度(像素) */
|
||||
width?: number
|
||||
/** 高度(像素) */
|
||||
height?: number
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number
|
||||
/** 编码格式 */
|
||||
codec?: string
|
||||
/** 帧率 */
|
||||
fps?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number
|
||||
/** 声道数 */
|
||||
channels?: number
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata: AssetMetadata
|
||||
file_size?: number
|
||||
file_url?: string
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number
|
||||
status?: string
|
||||
classification_status?: AssetClassificationStatus | null
|
||||
quality_score?: number | null
|
||||
tag_ids?: string[]
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 素材库 */
|
||||
export interface AssetLibraryItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
asset_count?: number
|
||||
total_size?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 入库任务 */
|
||||
export interface IngestJob {
|
||||
id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
error_message: string
|
||||
result_asset_id: string
|
||||
}
|
||||
|
||||
/** 分类任务 */
|
||||
export interface ClassificationJob {
|
||||
id: string
|
||||
asset_id: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
classification: string
|
||||
confidence: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
/** 素材诊断信息 */
|
||||
export interface AssetDiagnosis {
|
||||
readiness_score: number
|
||||
readiness_label: string
|
||||
total_assets: number
|
||||
ready_assets: number
|
||||
video_assets: number
|
||||
image_assets: number
|
||||
voice_assets: number
|
||||
total_duration_seconds: number
|
||||
estimated_video_count: number
|
||||
used_assets: number
|
||||
unused_assets: number
|
||||
pending_review_assets: number
|
||||
smart_views: Array<{
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
description: string
|
||||
}>
|
||||
gaps: Array<{
|
||||
key: string
|
||||
severity: "critical" | "warning" | "info"
|
||||
message: string
|
||||
recommendation: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ─── 素材库 ────────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
export const createAssetLibrary = async (data: {
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
// 后端要求 project_id,前端自动管理默认项目
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const response = await apiClient.post("/asset-libraries", {
|
||||
project_id: project.id,
|
||||
...data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
|
||||
export const ensureDefaultLibrary = async (data: {
|
||||
project_id: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
||||
}
|
||||
|
||||
// ─── 素材 ──────────────────────────────────────────────────
|
||||
|
||||
/** 获取素材库下的所有素材 */
|
||||
export const getAssets = async (
|
||||
libraryId: string,
|
||||
options?: { status?: string; page?: number; page_size?: number },
|
||||
): Promise<{ items: AssetItem[]; total: number }> => {
|
||||
const params: Record<string, string | number> = { library_id: libraryId }
|
||||
// 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed)
|
||||
// 让用户能看到"处理中"的素材,不会以为上传失败了
|
||||
if (options?.status) {
|
||||
params.status = options.status
|
||||
}
|
||||
if (options?.page) params.page = options.page
|
||||
if (options?.page_size) params.page_size = options.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
const data = response.data || {}
|
||||
const items: AssetItem[] = data.items || []
|
||||
const total: number = typeof data.total === "number" ? data.total : items.length
|
||||
return { items, total }
|
||||
}
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: {
|
||||
keyword?: string
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string | number> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
if (filters?.limit) params.limit = filters.limit
|
||||
if (filters?.page) params.page = filters.page
|
||||
if (filters?.page_size) params.page_size = filters.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||
export const createAsset = async (data: {
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata?: AssetMetadata
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: "pending_review" | "approved" | "rejected",
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`)
|
||||
}
|
||||
|
||||
// ─── 上传 ──────────────────────────────────────────────────
|
||||
|
||||
/** 表单上传素材(小文件) */
|
||||
export const uploadAsset = async (
|
||||
formData: FormData,
|
||||
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
|
||||
const response = await apiClient.post("/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 30 * 60 * 1000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
project_id: string
|
||||
library_id: string
|
||||
filename: string
|
||||
content_type: string
|
||||
file_size: number
|
||||
}): Promise<{
|
||||
upload_url: string
|
||||
method: string
|
||||
storage_key: string
|
||||
expires_at: string
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
}> => {
|
||||
const response = await apiClient.post("/upload/direct/prepare", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传完成确认 */
|
||||
export const completeDirectUpload = async (data: {
|
||||
project_id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
const response = await apiClient.post("/upload/direct/complete", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
// 后端要求 project_id,前端自动获取默认项目
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
})
|
||||
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", data.file)
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(prepared.method, prepared.upload_url)
|
||||
|
||||
// 超时 10 分钟
|
||||
xhr.timeout = 10 * 60 * 1000
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
// 解析 OSS 返回的 XML 错误信息
|
||||
let ossError = ""
|
||||
try {
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
|
||||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
|
||||
if (codeMatch || msgMatch) {
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
||||
}
|
||||
} catch {
|
||||
// 无法解析响应体
|
||||
}
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
|
||||
console.error("[OSS Upload] 直传失败:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
})
|
||||
reject(new Error(detail))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
console.error("[OSS Upload] 网络错误:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"))
|
||||
}
|
||||
xhr.ontimeout = () => {
|
||||
console.error("[OSS Upload] 上传超时:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
|
||||
}
|
||||
xhr.send(directForm)
|
||||
})
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 入库 / 分类任务 ───────────────────────────────────────
|
||||
|
||||
/** 查询入库任务状态 */
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
asset_id: string
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post("/classification-jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ─── 批量操作 ───────────────────────────────────────────────
|
||||
|
||||
/** 批量操作结果 */
|
||||
export interface BatchOperationResult {
|
||||
succeeded: string[]
|
||||
failed: string[]
|
||||
total: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
}
|
||||
|
||||
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
|
||||
const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
|
||||
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
|
||||
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
|
||||
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
|
||||
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
|
||||
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
|
||||
return { succeeded, failed, total, success_count, failure_count }
|
||||
}
|
||||
|
||||
/** 批量删除素材 */
|
||||
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
})
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
tags: string[]
|
||||
mode: "add" | "replace"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
category: string
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
smart_view: "recommended" | "caution" | "high_risk"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 素材 CRUD API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { AssetItem, AssetMetadata } from "./types"
|
||||
|
||||
/** 获取素材库下的所有素材 */
|
||||
export const getAssets = async (
|
||||
libraryId: string,
|
||||
options?: {
|
||||
status?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
},
|
||||
): Promise<{ items: AssetItem[]; total: number }> => {
|
||||
const params: Record<string, string | number> = { library_id: libraryId }
|
||||
if (options?.status) params.status = options.status
|
||||
if (options?.page) params.page = options.page
|
||||
if (options?.page_size) params.page_size = options.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
const data = response.data || {}
|
||||
const items: AssetItem[] = data.items || []
|
||||
const total: number = typeof data.total === "number" ? data.total : items.length
|
||||
return { items, total }
|
||||
}
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: {
|
||||
keyword?: string
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string | number> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
if (filters?.limit) params.limit = filters.limit
|
||||
if (filters?.page) params.page = filters.page
|
||||
if (filters?.page_size) params.page_size = filters.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||
export const createAsset = async (data: {
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata?: AssetMetadata
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: "pending_review" | "approved" | "rejected",
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 素材批量操作 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { BatchOperationResult } from "./types"
|
||||
|
||||
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
|
||||
export const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
|
||||
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
|
||||
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
|
||||
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
|
||||
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
|
||||
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
|
||||
return { succeeded, failed, total, success_count, failure_count }
|
||||
}
|
||||
|
||||
/** 批量删除素材 */
|
||||
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
})
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
tags: string[]
|
||||
mode: "add" | "replace"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
category: string
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
smart_view: "recommended" | "caution" | "high_risk"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 素材诊断 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { AssetDiagnosis } from "./types"
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 素材相关 API — 按模块拆分后的统一入口
|
||||
* 保持与原 assets.ts 相同的导出结构,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
AssetMetadata,
|
||||
AssetClassificationStatus,
|
||||
AssetItem,
|
||||
AssetLibraryItem,
|
||||
IngestJob,
|
||||
ClassificationJob,
|
||||
AssetDiagnosis,
|
||||
BatchOperationResult,
|
||||
UploadResult,
|
||||
DirectUploadPrepareResult,
|
||||
DirectUploadCompleteResult,
|
||||
} from "./types"
|
||||
|
||||
// 素材诊断
|
||||
export { getAssetDiagnosis } from "./diagnosis"
|
||||
|
||||
// 素材库
|
||||
export {
|
||||
getAssetLibraries,
|
||||
createAssetLibrary,
|
||||
ensureDefaultLibrary,
|
||||
deleteAssetLibrary,
|
||||
} from "./libraries"
|
||||
|
||||
// 素材 CRUD
|
||||
export {
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
deleteAsset,
|
||||
} from "./assets"
|
||||
|
||||
// 上传
|
||||
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
|
||||
// 任务
|
||||
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
|
||||
|
||||
// 批量操作
|
||||
export {
|
||||
normalizeBatchResult,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
} from "./batch"
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 入库任务 & 分类任务 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { IngestJob, ClassificationJob } from "./types"
|
||||
|
||||
/** 查询入库任务状态 */
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
asset_id: string
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post("/classification-jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 素材库 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetLibraryItem } from "./types"
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
export const createAssetLibrary = async (data: {
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const response = await apiClient.post("/asset-libraries", {
|
||||
project_id: project.id,
|
||||
...data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 确保项目下指定 kind 的默认素材库存在 */
|
||||
export const ensureDefaultLibrary = async (data: {
|
||||
project_id: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 素材相关类型定义
|
||||
*/
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number
|
||||
/** 宽度(像素) */
|
||||
width?: number
|
||||
/** 高度(像素) */
|
||||
height?: number
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number
|
||||
/** 编码格式 */
|
||||
codec?: string
|
||||
/** 帧率 */
|
||||
fps?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number
|
||||
/** 声道数 */
|
||||
channels?: number
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata: AssetMetadata
|
||||
file_size?: number
|
||||
file_url?: string
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number
|
||||
status?: string
|
||||
classification_status?: AssetClassificationStatus | null
|
||||
quality_score?: number | null
|
||||
tag_ids?: string[]
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 素材库 */
|
||||
export interface AssetLibraryItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
asset_count?: number
|
||||
total_size?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 入库任务 */
|
||||
export interface IngestJob {
|
||||
id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
error_message: string
|
||||
result_asset_id: string
|
||||
}
|
||||
|
||||
/** 分类任务 */
|
||||
export interface ClassificationJob {
|
||||
id: string
|
||||
asset_id: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
classification: string
|
||||
confidence: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
/** 素材诊断信息 */
|
||||
export interface AssetDiagnosis {
|
||||
readiness_score: number
|
||||
readiness_label: string
|
||||
total_assets: number
|
||||
ready_assets: number
|
||||
video_assets: number
|
||||
image_assets: number
|
||||
voice_assets: number
|
||||
total_duration_seconds: number
|
||||
estimated_video_count: number
|
||||
used_assets: number
|
||||
unused_assets: number
|
||||
pending_review_assets: number
|
||||
smart_views: Array<{
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
description: string
|
||||
}>
|
||||
gaps: Array<{
|
||||
key: string
|
||||
severity: "critical" | "warning" | "info"
|
||||
message: string
|
||||
recommendation: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** 批量操作结果 */
|
||||
export interface BatchOperationResult {
|
||||
succeeded: string[]
|
||||
failed: string[]
|
||||
total: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
}
|
||||
|
||||
/** 上传返回 */
|
||||
export interface UploadResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 预签名直传准备返回 */
|
||||
export interface DirectUploadPrepareResult {
|
||||
upload_url: string
|
||||
method: string
|
||||
storage_key: string
|
||||
expires_at: string
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
export interface DirectUploadCompleteResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 上传相关 API(表单上传 + OSS 直传)
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||||
|
||||
/** 表单上传素材(小文件) */
|
||||
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
|
||||
const response = await apiClient.post("/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 30 * 60 * 1000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
project_id: string
|
||||
library_id: string
|
||||
filename: string
|
||||
content_type: string
|
||||
file_size: number
|
||||
}): Promise<DirectUploadPrepareResult> => {
|
||||
const response = await apiClient.post("/upload/direct/prepare", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传完成确认 */
|
||||
export const completeDirectUpload = async (data: {
|
||||
project_id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
const response = await apiClient.post("/upload/direct/complete", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
})
|
||||
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", data.file)
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(prepared.method, prepared.upload_url)
|
||||
|
||||
// 超时 10 分钟
|
||||
xhr.timeout = 10 * 60 * 1000
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
// 解析 OSS 返回的 XML 错误信息
|
||||
let ossError = ""
|
||||
try {
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
|
||||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
|
||||
if (codeMatch || msgMatch) {
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
||||
}
|
||||
} catch {
|
||||
// 无法解析响应体
|
||||
}
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
|
||||
console.error("[OSS Upload] 直传失败:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
})
|
||||
reject(new Error(detail))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
console.error("[OSS Upload] 网络错误:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"))
|
||||
}
|
||||
xhr.ontimeout = () => {
|
||||
console.error("[OSS Upload] 上传超时:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
|
||||
}
|
||||
xhr.send(directForm)
|
||||
})
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
/**
|
||||
* 视频库页面 — V21 设计系统
|
||||
* 两栏布局:左侧视频库列表(260px)+ 右侧素材网格
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 数据查询 → hooks/useAssetsData
|
||||
* 库管理 → hooks/useLibraryManagement
|
||||
* 上传 → hooks/useAssetUpload
|
||||
* 选中态 → hooks/useAssetSelection
|
||||
* 素材操作 → hooks/useAssetOperations
|
||||
* 上传区 → components/AssetUploadZone
|
||||
* 网格区 → components/AssetGridSection
|
||||
* 弹窗集合 → components/AssetModals
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { Upload } from "antd"
|
||||
import { InboxOutlined, PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
import type { SmartViewType } from "@/pages/assets/components/BatchMarkModal"
|
||||
import AssetCard from "@/pages/assets/components/AssetCard"
|
||||
import { SkeletonCard } from "@/pages/assets/components/AssetSkeleton"
|
||||
import LibrarySidebar from "@/pages/assets/components/LibrarySidebar"
|
||||
import AssetFilterBar from "@/pages/assets/components/AssetFilterBar"
|
||||
import BatchOperationBar from "@/pages/assets/components/BatchOperationBar"
|
||||
import AssetUploadZone from "@/pages/assets/components/AssetUploadZone"
|
||||
import AssetGridSection from "@/pages/assets/components/AssetGridSection"
|
||||
import AssetModals from "@/pages/assets/components/AssetModals"
|
||||
import CreateLibraryModal from "@/pages/assets/components/CreateLibraryModal"
|
||||
import PlayModal from "@/pages/assets/components/PlayModal"
|
||||
import BatchTagModal from "@/pages/assets/components/BatchTagModal"
|
||||
import BatchClassifyModal from "@/pages/assets/components/BatchClassifyModal"
|
||||
import BatchMarkModal from "@/pages/assets/components/BatchMarkModal"
|
||||
import ResultDrawer from "@/pages/assets/components/ResultDrawer"
|
||||
import UploadProgressModal from "@/pages/assets/components/UploadProgressModal"
|
||||
import { useAssetsData } from "@/pages/assets/hooks/useAssetsData"
|
||||
import { useLibraryManagement } from "@/pages/assets/hooks/useLibraryManagement"
|
||||
import { useAssetUpload } from "@/pages/assets/hooks/useAssetUpload"
|
||||
@@ -29,6 +27,9 @@ import { useAssetSelection } from "@/pages/assets/hooks/useAssetSelection"
|
||||
import { useAssetOperations } from "@/pages/assets/hooks/useAssetOperations"
|
||||
import "./assets.css"
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const AssetLibrary: React.FC = () => {
|
||||
/* ── 数据查询与筛选 ── */
|
||||
const {
|
||||
@@ -128,6 +129,9 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* ─── 上传进度弹窗 ─── */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:视频库列表 ─── */}
|
||||
@@ -142,11 +146,25 @@ const AssetLibrary: React.FC = () => {
|
||||
{/* ─── 右侧:内容区 ─── */}
|
||||
<div className="xx-assets-content">
|
||||
{/* 上传区域 */}
|
||||
<AssetUploadZone
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
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>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<AssetFilterBar
|
||||
@@ -173,69 +191,114 @@ const AssetLibrary: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 素材网格 */}
|
||||
<AssetGridSection
|
||||
loading={assetsLoading}
|
||||
error={assetsError}
|
||||
errorMessage={assetsErrorObj?.message}
|
||||
assets={filteredAssets}
|
||||
selectedIds={selectedIds}
|
||||
diagnosingId={diagnosingId}
|
||||
onRetry={refetchAssets}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDiagnose={handleDiagnose}
|
||||
onPlay={setPlayingAsset}
|
||||
onDelete={handleSingleDelete}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* ─── 弹窗集合 ─── */}
|
||||
<AssetModals
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
createModalOpen={createModalOpen}
|
||||
onCreateModalCancel={() => setCreateModalOpen(false)}
|
||||
onCreateModalOk={handleCreateLibrary}
|
||||
newLibName={newLibName}
|
||||
onNewLibNameChange={setNewLibName}
|
||||
newLibKind={newLibKind}
|
||||
onNewLibKindChange={setNewLibKind}
|
||||
createLoading={isCreating}
|
||||
playingAsset={playingAsset}
|
||||
onPlayClose={() => setPlayingAsset(null)}
|
||||
tagModalOpen={tagModalOpen}
|
||||
{/* ─── 新建视频库弹窗 ─── */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onOk={handleCreateLibrary}
|
||||
name={newLibName}
|
||||
onNameChange={setNewLibName}
|
||||
kind={newLibKind}
|
||||
onKindChange={setNewLibKind}
|
||||
confirmLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* ─── 视频/音频播放弹窗 ─── */}
|
||||
<PlayModal open={!!playingAsset} asset={playingAsset} onClose={() => setPlayingAsset(null)} />
|
||||
|
||||
{/* ─── 批量打标签弹窗 ─── */}
|
||||
<BatchTagModal
|
||||
open={tagModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onTagCancel={() => {
|
||||
onCancel={() => {
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
}}
|
||||
onTagOk={handleBatchTag}
|
||||
batchTags={batchTags}
|
||||
batchTagInput={batchTagInput}
|
||||
onOk={handleBatchTag}
|
||||
tags={batchTags}
|
||||
onTagInputChange={setBatchTagInput}
|
||||
onTagInputKeyDown={handleTagInputKeyDown}
|
||||
onRemoveTag={removeBatchTag}
|
||||
tagMode={tagMode as "add" | "replace"}
|
||||
onTagModeChange={setTagMode as (mode: "add" | "replace") => void}
|
||||
batchLoading={batchLoading}
|
||||
classifyModalOpen={classifyModalOpen}
|
||||
onClassifyCancel={() => {
|
||||
tagInput={batchTagInput}
|
||||
tagMode={tagMode}
|
||||
onTagModeChange={setTagMode}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* ─── 批量改分类弹窗 ─── */}
|
||||
<BatchClassifyModal
|
||||
open={classifyModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => {
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
}}
|
||||
onClassifyOk={handleBatchClassify}
|
||||
batchCategory={batchCategory}
|
||||
onOk={handleBatchClassify}
|
||||
category={batchCategory}
|
||||
onCategoryChange={setBatchCategory}
|
||||
markModalOpen={markModalOpen}
|
||||
onMarkCancel={() => setMarkModalOpen(false)}
|
||||
onMarkOk={handleBatchMark}
|
||||
batchSmartView={batchSmartView as SmartViewType}
|
||||
onSmartViewChange={setBatchSmartView as (val: SmartViewType) => void}
|
||||
resultDrawerOpen={resultDrawerOpen}
|
||||
operationTitle={operationTitle}
|
||||
operationResult={operationResult}
|
||||
onResultDrawerClose={handleResultDrawerClose}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* ─── 批量智能标记弹窗 ─── */}
|
||||
<BatchMarkModal
|
||||
open={markModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => setMarkModalOpen(false)}
|
||||
onOk={handleBatchMark}
|
||||
smartView={batchSmartView}
|
||||
onSmartViewChange={setBatchSmartView}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* ─── 操作结果 Drawer ─── */}
|
||||
<ResultDrawer
|
||||
open={resultDrawerOpen}
|
||||
title={operationTitle}
|
||||
result={operationResult}
|
||||
onClose={handleResultDrawerClose}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* AssetLibrary 素材网格区域(含加载/错误/空状态)
|
||||
*/
|
||||
import React from "react"
|
||||
import { PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { AssetItem } from "../types"
|
||||
import AssetCard from "./AssetCard"
|
||||
import { SkeletonCard } from "./AssetSkeleton"
|
||||
|
||||
export interface AssetGridSectionProps {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
errorMessage?: string
|
||||
assets: AssetItem[]
|
||||
selectedIds: Set<string>
|
||||
diagnosingId: string | null
|
||||
onRetry?: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onDiagnose: (asset: AssetItem) => void
|
||||
onPlay: (asset: AssetItem) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
loading,
|
||||
error,
|
||||
errorMessage,
|
||||
assets,
|
||||
selectedIds,
|
||||
diagnosingId,
|
||||
onRetry,
|
||||
onToggleSelect,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="xx-asset-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">{errorMessage || "加载失败"}</p>
|
||||
{onRetry && (
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onRetry}>
|
||||
重新加载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (assets.length > 0) {
|
||||
return (
|
||||
<div className="xx-asset-grid">
|
||||
{assets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
onToggle={() => onToggleSelect(asset.id)}
|
||||
onDiagnose={() => onDiagnose(asset)}
|
||||
onPlay={() => onPlay(asset)}
|
||||
onDelete={() => onDelete(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换视频库</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetGridSection
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* AssetLibrary 弹窗集合
|
||||
*/
|
||||
import React from "react"
|
||||
import type { AssetItem, AssetKind } from "../types"
|
||||
import type { SmartViewType } from "./BatchMarkModal"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
import CreateLibraryModal from "./CreateLibraryModal"
|
||||
import PlayModal from "./PlayModal"
|
||||
import BatchTagModal from "./BatchTagModal"
|
||||
import BatchClassifyModal from "./BatchClassifyModal"
|
||||
import BatchMarkModal from "./BatchMarkModal"
|
||||
import ResultDrawer from "./ResultDrawer"
|
||||
import UploadProgressModal from "./UploadProgressModal"
|
||||
|
||||
export interface AssetModalsProps {
|
||||
/* 上传进度 */
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
|
||||
/* 新建视频库 */
|
||||
createModalOpen: boolean
|
||||
onCreateModalCancel: () => void
|
||||
onCreateModalOk: () => void
|
||||
newLibName: string
|
||||
onNewLibNameChange: (name: string) => void
|
||||
newLibKind: AssetKind
|
||||
onNewLibKindChange: (kind: AssetKind) => void
|
||||
createLoading: boolean
|
||||
|
||||
/* 播放弹窗 */
|
||||
playingAsset: AssetItem | null
|
||||
onPlayClose: () => void
|
||||
|
||||
/* 批量打标签 */
|
||||
tagModalOpen: boolean
|
||||
selectedCount: number
|
||||
onTagCancel: () => void
|
||||
onTagOk: () => void
|
||||
batchTags: string[]
|
||||
batchTagInput: string
|
||||
onTagInputChange: (val: string) => void
|
||||
onTagInputKeyDown: (e: React.KeyboardEvent) => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
tagMode: "add" | "replace"
|
||||
onTagModeChange: (mode: "add" | "replace") => void
|
||||
batchLoading: boolean
|
||||
|
||||
/* 批量改分类 */
|
||||
classifyModalOpen: boolean
|
||||
onClassifyCancel: () => void
|
||||
onClassifyOk: () => void
|
||||
batchCategory: string
|
||||
onCategoryChange: (val: string) => void
|
||||
|
||||
/* 批量智能标记 */
|
||||
markModalOpen: boolean
|
||||
onMarkCancel: () => void
|
||||
onMarkOk: () => void
|
||||
batchSmartView: SmartViewType
|
||||
onSmartViewChange: (val: SmartViewType) => void
|
||||
|
||||
/* 操作结果 Drawer */
|
||||
resultDrawerOpen: boolean
|
||||
operationTitle: string
|
||||
operationResult: BatchOperationResult | null
|
||||
onResultDrawerClose: () => void
|
||||
}
|
||||
|
||||
export const AssetModals: React.FC<AssetModalsProps> = ({
|
||||
uploading,
|
||||
uploadProgress,
|
||||
createModalOpen,
|
||||
onCreateModalCancel,
|
||||
onCreateModalOk,
|
||||
newLibName,
|
||||
onNewLibNameChange,
|
||||
newLibKind,
|
||||
onNewLibKindChange,
|
||||
createLoading,
|
||||
playingAsset,
|
||||
onPlayClose,
|
||||
tagModalOpen,
|
||||
selectedCount,
|
||||
onTagCancel,
|
||||
onTagOk,
|
||||
batchTags,
|
||||
batchTagInput,
|
||||
onTagInputChange,
|
||||
onTagInputKeyDown,
|
||||
onRemoveTag,
|
||||
tagMode,
|
||||
onTagModeChange,
|
||||
batchLoading,
|
||||
classifyModalOpen,
|
||||
onClassifyCancel,
|
||||
onClassifyOk,
|
||||
batchCategory,
|
||||
onCategoryChange,
|
||||
markModalOpen,
|
||||
onMarkCancel,
|
||||
onMarkOk,
|
||||
batchSmartView,
|
||||
onSmartViewChange,
|
||||
resultDrawerOpen,
|
||||
operationTitle,
|
||||
operationResult,
|
||||
onResultDrawerClose,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 上传进度弹窗 */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 新建视频库弹窗 */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
onCancel={onCreateModalCancel}
|
||||
onOk={onCreateModalOk}
|
||||
name={newLibName}
|
||||
onNameChange={onNewLibNameChange}
|
||||
kind={newLibKind}
|
||||
onKindChange={onNewLibKindChange}
|
||||
confirmLoading={createLoading}
|
||||
/>
|
||||
|
||||
{/* 视频/音频播放弹窗 */}
|
||||
<PlayModal open={!!playingAsset} asset={playingAsset} onClose={onPlayClose} />
|
||||
|
||||
{/* 批量打标签弹窗 */}
|
||||
<BatchTagModal
|
||||
open={tagModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onTagCancel}
|
||||
onOk={onTagOk}
|
||||
tags={batchTags}
|
||||
onTagInputChange={onTagInputChange}
|
||||
onTagInputKeyDown={onTagInputKeyDown}
|
||||
onRemoveTag={onRemoveTag}
|
||||
tagInput={batchTagInput}
|
||||
tagMode={tagMode}
|
||||
onTagModeChange={onTagModeChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 批量改分类弹窗 */}
|
||||
<BatchClassifyModal
|
||||
open={classifyModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onClassifyCancel}
|
||||
onOk={onClassifyOk}
|
||||
category={batchCategory}
|
||||
onCategoryChange={onCategoryChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 批量智能标记弹窗 */}
|
||||
<BatchMarkModal
|
||||
open={markModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onMarkCancel}
|
||||
onOk={onMarkOk}
|
||||
smartView={batchSmartView}
|
||||
onSmartViewChange={onSmartViewChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 操作结果 Drawer */}
|
||||
<ResultDrawer
|
||||
open={resultDrawerOpen}
|
||||
title={operationTitle}
|
||||
result={operationResult}
|
||||
onClose={onResultDrawerClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetModals
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* AssetLibrary 上传拖拽区域
|
||||
*/
|
||||
import React from "react"
|
||||
import { Upload } from "antd"
|
||||
import { InboxOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface AssetUploadZoneProps {
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
onUpload: (file: File) => void
|
||||
}
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
onUpload,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
onUpload(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>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetUploadZone
|
||||
Reference in New Issue
Block a user