From d85729873764c7f4f99a3f11c9786e16b129e9ce Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 16:51:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor(assets):=20API=E5=B1=82=E6=8C=89?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8B=86=E5=88=86=EF=BC=88443=E8=A1=8C?= =?UTF-8?q?=E2=86=92=E5=85=A5=E5=8F=A377=E8=A1=8C,=20-83%=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 assets.ts 单文件拆分为目录结构: - types.ts — 所有类型定义 - diagnosis.ts — 素材诊断 API - libraries.ts — 素材库 CRUD - assets.ts — 素材 CRUD - upload.ts — 表单上传 + OSS 直传(带进度/超时/错误诊断) - jobs.ts — 入库任务 + 分类任务 - batch.ts — 批量操作(删除/打标签/分类/智能标记)+ 归一化工具 - index.ts — 统一入口,re-export 所有类型和函数 入口文件 443 行 → 77 行,各模块职责单一。 导入路径 @/api/assets 保持向后兼容。 --- apps/web/src/api/assets.ts | 443 --------------------------- apps/web/src/api/assets/assets.ts | 87 ++++++ apps/web/src/api/assets/batch.ts | 51 +++ apps/web/src/api/assets/diagnosis.ts | 13 + apps/web/src/api/assets/index.ts | 55 ++++ apps/web/src/api/assets/jobs.ts | 25 ++ apps/web/src/api/assets/libraries.ts | 39 +++ apps/web/src/api/assets/types.ts | 138 +++++++++ apps/web/src/api/assets/upload.ts | 119 +++++++ 9 files changed, 527 insertions(+), 443 deletions(-) delete mode 100644 apps/web/src/api/assets.ts create mode 100644 apps/web/src/api/assets/assets.ts create mode 100644 apps/web/src/api/assets/batch.ts create mode 100644 apps/web/src/api/assets/diagnosis.ts create mode 100644 apps/web/src/api/assets/index.ts create mode 100644 apps/web/src/api/assets/jobs.ts create mode 100644 apps/web/src/api/assets/libraries.ts create mode 100644 apps/web/src/api/assets/types.ts create mode 100644 apps/web/src/api/assets/upload.ts diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts deleted file mode 100644 index 8cb2aa49b..000000000 --- a/apps/web/src/api/assets.ts +++ /dev/null @@ -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 => { - const params: Record = {} - if (assetId) params.asset_id = assetId - const response = await apiClient.get("/asset-diagnosis", { params }) - return response.data -} - -// ─── 素材库 ──────────────────────────────────────────────── - -/** 获取当前用户的所有素材库 */ -export const getAssetLibraries = async (): Promise => { - 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 => { - // 后端要求 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 => { - const response = await apiClient.post("/asset-libraries/ensure-default", data) - return response.data -} - -/** 删除素材库 */ -export const deleteAssetLibrary = async (libraryId: string): Promise => { - 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 = { 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 => { - const params: Record = { 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 => { - const response = await apiClient.post("/assets", data) - return response.data -} - -/** 更新素材(名称、metadata 等) */ -export const updateAsset = async ( - assetId: string, - data: { name?: string; metadata?: AssetMetadata }, -): Promise => { - const response = await apiClient.put(`/assets/${assetId}`, data) - return response.data -} - -/** 更新素材审核状态 */ -export const updateAssetReviewStatus = async ( - assetId: string, - reviewStatus: "pending_review" | "approved" | "rejected", -): Promise => { - const response = await apiClient.patch(`/assets/${assetId}/review`, { - review_status: reviewStatus, - }) - return response.data -} - -/** 删除素材 */ -export const deleteAsset = async (assetId: string): Promise => { - 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 - 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((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>/) - const msgMatch = xhr.responseText.match(/([^<]+)<\/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 => { - const response = await apiClient.get(`/ingest-jobs/${jobId}`) - return response.data -} - -/** 提交素材分类任务 */ -export const submitClassificationJob = async (data: { - asset_id: string -}): Promise => { - const response = await apiClient.post("/classification-jobs", data) - return response.data -} - -/** 查询分类任务状态 */ -export const getClassificationJob = async (jobId: string): Promise => { - 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): 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 => { - const response = await apiClient.post("/assets/batch-delete", { - asset_ids: assetIds, - }) - return normalizeBatchResult((response.data || {}) as Record) -} - -/** 批量打标签 */ -export const batchTagAssets = async (data: { - asset_ids: string[] - tags: string[] - mode: "add" | "replace" -}): Promise => { - const response = await apiClient.post("/assets/batch-tag", data) - return normalizeBatchResult((response.data || {}) as Record) -} - -/** 批量改分类 */ -export const batchClassifyAssets = async (data: { - asset_ids: string[] - category: string -}): Promise => { - const response = await apiClient.post("/assets/batch-classify", data) - return normalizeBatchResult((response.data || {}) as Record) -} - -/** 批量智能标记 */ -export const batchMarkAssets = async (data: { - asset_ids: string[] - smart_view: "recommended" | "caution" | "high_risk" -}): Promise => { - const response = await apiClient.post("/assets/batch-mark", data) - return normalizeBatchResult((response.data || {}) as Record) -} diff --git a/apps/web/src/api/assets/assets.ts b/apps/web/src/api/assets/assets.ts new file mode 100644 index 000000000..902aface1 --- /dev/null +++ b/apps/web/src/api/assets/assets.ts @@ -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 = { 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 => { + const params: Record = { 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 => { + const response = await apiClient.post("/assets", data) + return response.data +} + +/** 更新素材(名称、metadata 等) */ +export const updateAsset = async ( + assetId: string, + data: { name?: string; metadata?: AssetMetadata }, +): Promise => { + const response = await apiClient.put(`/assets/${assetId}`, data) + return response.data +} + +/** 更新素材审核状态 */ +export const updateAssetReviewStatus = async ( + assetId: string, + reviewStatus: "pending_review" | "approved" | "rejected", +): Promise => { + const response = await apiClient.patch(`/assets/${assetId}/review`, { + review_status: reviewStatus, + }) + return response.data +} + +/** 删除素材 */ +export const deleteAsset = async (assetId: string): Promise => { + await apiClient.delete(`/assets/${assetId}`) +} diff --git a/apps/web/src/api/assets/batch.ts b/apps/web/src/api/assets/batch.ts new file mode 100644 index 000000000..cd52c5728 --- /dev/null +++ b/apps/web/src/api/assets/batch.ts @@ -0,0 +1,51 @@ +/** + * 素材批量操作 API + */ +import apiClient from "../client" +import type { BatchOperationResult } from "./types" + +/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */ +export const normalizeBatchResult = (raw: Record): 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 => { + const response = await apiClient.post("/assets/batch-delete", { + asset_ids: assetIds, + }) + return normalizeBatchResult((response.data || {}) as Record) +} + +/** 批量打标签 */ +export const batchTagAssets = async (data: { + asset_ids: string[] + tags: string[] + mode: "add" | "replace" +}): Promise => { + const response = await apiClient.post("/assets/batch-tag", data) + return normalizeBatchResult((response.data || {}) as Record) +} + +/** 批量改分类 */ +export const batchClassifyAssets = async (data: { + asset_ids: string[] + category: string +}): Promise => { + const response = await apiClient.post("/assets/batch-classify", data) + return normalizeBatchResult((response.data || {}) as Record) +} + +/** 批量智能标记 */ +export const batchMarkAssets = async (data: { + asset_ids: string[] + smart_view: "recommended" | "caution" | "high_risk" +}): Promise => { + const response = await apiClient.post("/assets/batch-mark", data) + return normalizeBatchResult((response.data || {}) as Record) +} diff --git a/apps/web/src/api/assets/diagnosis.ts b/apps/web/src/api/assets/diagnosis.ts new file mode 100644 index 000000000..f3628530e --- /dev/null +++ b/apps/web/src/api/assets/diagnosis.ts @@ -0,0 +1,13 @@ +/** + * 素材诊断 API + */ +import apiClient from "../client" +import type { AssetDiagnosis } from "./types" + +/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ +export const getAssetDiagnosis = async (assetId?: string): Promise => { + const params: Record = {} + if (assetId) params.asset_id = assetId + const response = await apiClient.get("/asset-diagnosis", { params }) + return response.data +} diff --git a/apps/web/src/api/assets/index.ts b/apps/web/src/api/assets/index.ts new file mode 100644 index 000000000..a69db9a6c --- /dev/null +++ b/apps/web/src/api/assets/index.ts @@ -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" diff --git a/apps/web/src/api/assets/jobs.ts b/apps/web/src/api/assets/jobs.ts new file mode 100644 index 000000000..e994cdcfc --- /dev/null +++ b/apps/web/src/api/assets/jobs.ts @@ -0,0 +1,25 @@ +/** + * 入库任务 & 分类任务 API + */ +import apiClient from "../client" +import type { IngestJob, ClassificationJob } from "./types" + +/** 查询入库任务状态 */ +export const getIngestJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/ingest-jobs/${jobId}`) + return response.data +} + +/** 提交素材分类任务 */ +export const submitClassificationJob = async (data: { + asset_id: string +}): Promise => { + const response = await apiClient.post("/classification-jobs", data) + return response.data +} + +/** 查询分类任务状态 */ +export const getClassificationJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/classification-jobs/${jobId}`) + return response.data +} diff --git a/apps/web/src/api/assets/libraries.ts b/apps/web/src/api/assets/libraries.ts new file mode 100644 index 000000000..4d3327116 --- /dev/null +++ b/apps/web/src/api/assets/libraries.ts @@ -0,0 +1,39 @@ +/** + * 素材库 API + */ +import apiClient from "../client" +import { getOrCreateDefaultProject } from "../projects" +import type { AssetLibraryItem } from "./types" + +/** 获取当前用户的所有素材库 */ +export const getAssetLibraries = async (): Promise => { + 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 => { + 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 => { + const response = await apiClient.post("/asset-libraries/ensure-default", data) + return response.data +} + +/** 删除素材库 */ +export const deleteAssetLibrary = async (libraryId: string): Promise => { + await apiClient.delete(`/asset-libraries/${libraryId}`) +} diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts new file mode 100644 index 000000000..9e64a942e --- /dev/null +++ b/apps/web/src/api/assets/types.ts @@ -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 + max_size_bytes: number +} + +/** 直传完成确认返回 */ +export interface DirectUploadCompleteResult { + storage_key: string + ingest_job_id: string +} diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts new file mode 100644 index 000000000..874861f1f --- /dev/null +++ b/apps/web/src/api/assets/upload.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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((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>/) + const msgMatch = xhr.responseText.match(/([^<]+)<\/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, + }) +} -- 2.54.0