Files
xiaoxia-saas/apps/web/src/api/assets.ts
T
CI Bot 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
refactor: 消除全部 Record<string, unknown>,替换为具体类型定义
- 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> 残留
2026-07-14 18:41:25 +08:00

430 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 素材相关 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;
};