3862996045
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Waiting to run
CI/CD Pipeline / PR Build Web Image (push) Waiting to run
CI/CD Pipeline / PR Build Worker Image (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Waiting to run
CI/CD Pipeline / Build Production Web Image (push) Waiting to run
CI/CD Pipeline / Build Production Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
/**
|
||
* 素材相关 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>)
|
||
}
|