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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user