8d86707e2e
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m57s
- GeneratePage.tsx: 3处防御性错误提取函数添加 eslint-disable 注释 (safeExtract/extractString/safeExtractErr),移除 as Record<string, unknown> 断言 - TemplateLibrary.tsx: formatConfig 使用 ConfigDisplayFields 接口替代 Record 断言 - VoiceLibrary.tsx: buildVoiceMetadata 返回 VoiceUploadMetadata 接口 - VoiceMaterialLibrary.tsx: buildMetadata 返回 VoiceAssetMetadata 接口 - editPlans.ts: EditPlanConfig 去除索引签名,补充所有生成时字段 - assets.ts: AssetItem.metadata 使用 AssetMetadata 具体类型 - products.ts: GenerateFromTemplateRequest.config 改为 EditPlanConfig - tts.ts/voiceClone.ts: metadata 参数使用具体接口 - editingPlanner.ts: ValidationWarningDetails 替代 Record<string, unknown> - client.ts: 防御性错误提取添加 eslint-disable 注释 TypeScript 编译零错误,代码库中零 Record<string, unknown> 残留
430 lines
13 KiB
TypeScript
430 lines
13 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): Promise<AssetItem[]> => {
|
||
const response = await apiClient.get("/assets", {
|
||
params: { library_id: libraryId },
|
||
});
|
||
return response.data.items || [];
|
||
};
|
||
|
||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||
export const getAssetsByKind = async (
|
||
kind: string,
|
||
filters?: {
|
||
keyword?: string;
|
||
gender?: string;
|
||
style?: string;
|
||
tag_ids?: string[];
|
||
},
|
||
): Promise<AssetItem[]> => {
|
||
const params: Record<string, string> = { 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(",");
|
||
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;
|
||
}
|
||
|
||
/** 批量删除素材 */
|
||
export const batchDeleteAssets = async (
|
||
assetIds: string[],
|
||
): Promise<BatchOperationResult> => {
|
||
const response = await apiClient.post("/assets/batch-delete", {
|
||
asset_ids: assetIds,
|
||
});
|
||
return response.data;
|
||
};
|
||
|
||
/** 批量打标签 */
|
||
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 response.data;
|
||
};
|
||
|
||
/** 批量改分类 */
|
||
export const batchClassifyAssets = async (data: {
|
||
asset_ids: string[];
|
||
category: string;
|
||
}): Promise<BatchOperationResult> => {
|
||
const response = await apiClient.post("/assets/batch-classify", data);
|
||
return response.data;
|
||
};
|
||
|
||
/** 批量智能标记 */
|
||
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 response.data;
|
||
};
|