Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 41eafee545 Merge branch 'develop' into refactor/assets-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m20s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m0s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m10s
AI Code Review / AI Code Review (pull_request) Successful in 1m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m27s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 19s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:35:02 +08:00
xiaoxia d857298737 refactor(assets): API层按模块拆分(443行→入口77行, -83%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 21s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m3s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 41s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 44s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
将 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 保持向后兼容。
2026-07-26 16:51:36 +08:00
15 changed files with 793 additions and 838 deletions
-443
View File
@@ -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>)
}
+87
View File
@@ -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}`)
}
+51
View File
@@ -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>)
}
+13
View File
@@ -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
}
+55
View File
@@ -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"
+25
View File
@@ -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
}
+39
View File
@@ -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}`)
}
+138
View File
@@ -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
}
+119
View File
@@ -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,
})
}
+266 -49
View File
@@ -1,21 +1,8 @@
/**
* 上传音频弹窗
*
* 子组件目录: upload-voice/
* - UploadDragger: 拖拽上传区
* - SelectedFileInfo: 已选文件信息
* - UploadProgress: 上传进度条
* - UploadFormFields: 表单区域(名称/性别/描述)
* - UploadActions: 底部操作按钮
*/
import React from "react"
import { Modal } from "antd"
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
import { Modal, Upload, message } from "antd"
import { type VoiceGender } from "@/pages/voices/types"
import UploadDragger from "./upload-voice/UploadDragger"
import SelectedFileInfo from "./upload-voice/SelectedFileInfo"
import UploadProgress from "./upload-voice/UploadProgress"
import UploadFormFields from "./upload-voice/UploadFormFields"
import UploadActions from "./upload-voice/UploadActions"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface UploadVoiceModalProps {
open: boolean
@@ -33,6 +20,7 @@ export interface UploadVoiceModalProps {
onUpload: () => void
}
/** 上传音频弹窗 */
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
open,
uploadFile,
@@ -48,21 +36,17 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
onDescChange,
onUpload,
}) => {
const isUploading = uploadProgress !== null
const handleClose = () => {
if (isUploading) return // 上传中不可关闭
onClose()
}
return (
<Modal
title="上传音频"
open={open}
onCancel={handleClose}
onCancel={() => {
if (uploadProgress !== null) return // 上传中不可关闭
onClose()
}}
footer={null}
width={520}
maskClosable={!isUploading}
maskClosable={uploadProgress === null}
>
<div
style={{
@@ -73,37 +57,270 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
}}
>
{/* 拖拽上传区 */}
<UploadDragger
disabled={isUploading}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
/>
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
{/* 已选文件信息 */}
{uploadFile && <SelectedFileInfo file={uploadFile} />}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{/* 上传进度 */}
{isUploading && <UploadProgress progress={uploadProgress!} />}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{/* 表单字段 */}
<UploadFormFields
name={uploadName}
gender={uploadGender}
description={uploadDesc}
disabled={isUploading}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescriptionChange={onDescChange}
/>
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onGenderChange(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => onDescChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 操作按钮 */}
<UploadActions
hasFile={!!uploadFile}
hasName={!!uploadName.trim()}
uploading={isUploading}
onCancel={onClose}
onUpload={onUpload}
/>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onClose}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件")
return
}
if (!uploadName.trim()) {
message.warning("请输入素材名称")
return
}
onUpload()
}}
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
</div>
</Modal>
)
@@ -1,45 +0,0 @@
/**
* 上传音频 - 已选文件信息
*/
import React from "react"
import { SoundOutlined } from "@ant-design/icons"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface SelectedFileInfoProps {
file: File
}
export const SelectedFileInfo: React.FC<SelectedFileInfoProps> = ({ file }) => {
return (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{file.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(file.size)}
</div>
</div>
</div>
)
}
export default SelectedFileInfo
@@ -1,82 +0,0 @@
/**
* 上传音频 - 底部操作按钮
*/
import React from "react"
import { message } from "antd"
export interface UploadActionsProps {
hasFile: boolean
hasName: boolean
uploading: boolean
onCancel: () => void
onUpload: () => void
}
export const UploadActions: React.FC<UploadActionsProps> = ({
hasFile,
hasName,
uploading,
onCancel,
onUpload,
}) => {
const canUpload = hasFile && hasName && !uploading
const handleUploadClick = () => {
if (!hasFile) {
message.warning("请先选择音频文件")
return
}
if (!hasName) {
message.warning("请输入素材名称")
return
}
onUpload()
}
return (
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onCancel}
disabled={uploading}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploading ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={handleUploadClick}
disabled={!canUpload}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background: canUpload ? "var(--primary-color)" : "var(--text-tertiary)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor: canUpload ? "pointer" : "not-allowed",
}}
>
{uploading ? "上传中..." : "开始上传"}
</button>
</div>
)
}
export default UploadActions
@@ -1,56 +0,0 @@
/**
* 上传音频 - 拖拽上传区
*/
import React from "react"
import { UploadOutlined } from "@ant-design/icons"
import { Upload } from "antd"
export interface UploadDraggerProps {
disabled: boolean
onFileSelect: (file: File) => void
onFileRemove: () => void
}
export const UploadDragger: React.FC<UploadDraggerProps> = ({
disabled,
onFileSelect,
onFileRemove,
}) => {
return (
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={disabled}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}></p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
)
}
export default UploadDragger
@@ -1,115 +0,0 @@
/**
* 上传音频 - 表单区域(名称/性别/描述)
*/
import React from "react"
import { type VoiceGender } from "@/pages/voices/types"
export interface UploadFormFieldsProps {
name: string
gender: VoiceGender
description: string
disabled: boolean
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescriptionChange: (desc: string) => void
}
const GENDER_LABELS: Record<VoiceGender, string> = {
female: "女声",
male: "男声",
child: "童声",
elderly: "老年音",
}
export const UploadFormFields: React.FC<UploadFormFieldsProps> = ({
name,
gender,
description,
disabled,
onNameChange,
onGenderChange,
onDescriptionChange,
}) => {
return (
<>
{/* 名称 */}
<div>
<div style={labelStyle}></div>
<input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={disabled}
style={inputStyle}
/>
</div>
{/* 性别选择 */}
<div>
<div style={labelStyle}></div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onGenderChange(g)}
disabled={disabled}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${gender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background: gender === g ? "var(--primary-soft)" : "transparent",
color: gender === g ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: gender === g ? 600 : 400,
cursor: disabled ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{GENDER_LABELS[g]}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div style={labelStyle}></div>
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={disabled}
style={{
...inputStyle,
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
</>
)
}
const labelStyle: React.CSSProperties = {
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}
export default UploadFormFields
@@ -1,48 +0,0 @@
/**
* 上传音频 - 上传进度条
*/
import React from "react"
export interface UploadProgressProps {
progress: number
}
export const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
return (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{progress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{progress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)
}
export default UploadProgress