ci: Prettier纳入两层防御体系 (#520)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #520.
This commit is contained in:
+219
-242
@@ -2,163 +2,157 @@
|
||||
* 素材相关 API
|
||||
* Phase 1 重构:去掉 project_id,素材直接归属用户
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getOrCreateDefaultProject } from "./projects";
|
||||
import apiClient from "./client"
|
||||
import { getOrCreateDefaultProject } from "./projects"
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 宽度(像素) */
|
||||
width?: number;
|
||||
width?: number
|
||||
/** 高度(像素) */
|
||||
height?: number;
|
||||
height?: number
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number;
|
||||
bitrate?: number
|
||||
/** 编码格式 */
|
||||
codec?: string;
|
||||
codec?: string
|
||||
/** 帧率 */
|
||||
fps?: number;
|
||||
fps?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
sample_rate?: number
|
||||
/** 声道数 */
|
||||
channels?: number;
|
||||
channels?: number
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
}>;
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
description: string
|
||||
}>
|
||||
gaps: Array<{
|
||||
key: string;
|
||||
severity: "critical" | "warning" | "info";
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}>;
|
||||
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 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 || [];
|
||||
};
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
export const createAssetLibrary = async (data: {
|
||||
name: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
// 后端要求 project_id,前端自动管理默认项目
|
||||
const project = await getOrCreateDefaultProject();
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const response = await apiClient.post("/asset-libraries", {
|
||||
project_id: project.id,
|
||||
...data,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
|
||||
export const ensureDefaultLibrary = async (data: {
|
||||
project_id: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
project_id: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
const response = await apiClient.post(
|
||||
"/asset-libraries/ensure-default",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
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}`);
|
||||
};
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
||||
}
|
||||
|
||||
// ─── 素材 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -166,49 +160,49 @@ export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
return response.data.items || [];
|
||||
};
|
||||
})
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: {
|
||||
keyword?: string;
|
||||
gender?: string;
|
||||
style?: string;
|
||||
tag_ids?: string[];
|
||||
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 || [];
|
||||
};
|
||||
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;
|
||||
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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
@@ -217,14 +211,14 @@ export const updateAssetReviewStatus = async (
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`);
|
||||
};
|
||||
await apiClient.delete(`/assets/${assetId}`)
|
||||
}
|
||||
|
||||
// ─── 上传 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -235,47 +229,47 @@ export const uploadAsset = async (
|
||||
const response = await apiClient.post("/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 30 * 60 * 1000,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
file_size: number;
|
||||
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;
|
||||
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;
|
||||
};
|
||||
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;
|
||||
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;
|
||||
};
|
||||
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;
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
// 后端要求 project_id,前端自动获取默认项目
|
||||
const project = await getOrCreateDefaultProject();
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
@@ -283,166 +277,149 @@ export const uploadAssetDirect = async (data: {
|
||||
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);
|
||||
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);
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(prepared.method, prepared.upload_url)
|
||||
|
||||
// 超时 10 分钟
|
||||
xhr.timeout = 10 * 60 * 1000;
|
||||
xhr.timeout = 10 * 60 * 1000
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
};
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
resolve()
|
||||
} else {
|
||||
// 解析 OSS 返回的 XML 错误信息
|
||||
let ossError = "";
|
||||
let ossError = ""
|
||||
try {
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
|
||||
const msgMatch = xhr.responseText.match(
|
||||
/<Message>([^<]+)<\/Message>/,
|
||||
);
|
||||
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"}]`;
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
||||
}
|
||||
} catch {
|
||||
// 无法解析响应体
|
||||
}
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
|
||||
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));
|
||||
})
|
||||
reject(new Error(detail))
|
||||
}
|
||||
};
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
console.error("[OSS Upload] 网络错误:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"));
|
||||
};
|
||||
})
|
||||
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);
|
||||
});
|
||||
})
|
||||
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;
|
||||
};
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
asset_id: string;
|
||||
asset_id: string
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post("/classification-jobs", data);
|
||||
return response.data;
|
||||
};
|
||||
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 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;
|
||||
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 };
|
||||
};
|
||||
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> => {
|
||||
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>);
|
||||
};
|
||||
})
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
tags: string[];
|
||||
mode: "add" | "replace";
|
||||
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>);
|
||||
};
|
||||
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;
|
||||
asset_ids: string[]
|
||||
category: string
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data);
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>);
|
||||
};
|
||||
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";
|
||||
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>);
|
||||
};
|
||||
const response = await apiClient.post("/assets/batch-mark", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeUser } from "./auth";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { normalizeUser } from "./auth"
|
||||
|
||||
describe("normalizeUser", () => {
|
||||
it("normalizes canonical API current-user fields", () => {
|
||||
@@ -20,8 +20,8 @@ describe("normalizeUser", () => {
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
created_at: undefined,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps compatibility with legacy UI-shaped user fields", () => {
|
||||
expect(
|
||||
@@ -42,6 +42,6 @@ describe("normalizeUser", () => {
|
||||
is_email_verified: false,
|
||||
email_verified: false,
|
||||
created_at: "2026-06-22T00:00:00Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+64
-72
@@ -1,58 +1,58 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios";
|
||||
import apiClient from "./client";
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string | null;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified: boolean;
|
||||
email_verified: boolean;
|
||||
created_at?: string;
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified?: boolean;
|
||||
email_verified?: boolean;
|
||||
created_at?: string;
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? "";
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false;
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
@@ -63,52 +63,46 @@ export const normalizeUser = (data: UserResponse): User => {
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (
|
||||
refreshToken: string,
|
||||
): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? "";
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (
|
||||
data: RegisterRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data);
|
||||
return response.data;
|
||||
};
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout");
|
||||
};
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me");
|
||||
return normalizeUser(response.data);
|
||||
};
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (
|
||||
email: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email });
|
||||
return response.data;
|
||||
};
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
@@ -118,14 +112,12 @@ export const resetPassword = async (
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (
|
||||
token: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token });
|
||||
return response.data;
|
||||
};
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
+25
-27
@@ -2,48 +2,48 @@
|
||||
* BGM 预设音乐 API
|
||||
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
/** BGM 风格分类 */
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
|
||||
|
||||
/** BGM 预设项 */
|
||||
export interface BgmPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: BgmCategory;
|
||||
id: string
|
||||
name: string
|
||||
category: BgmCategory
|
||||
/** 音频文件 URL */
|
||||
url: string;
|
||||
url: string
|
||||
/** 时长(秒) */
|
||||
duration: number;
|
||||
duration: number
|
||||
/** 关键词标签 */
|
||||
tags: string[];
|
||||
tags: string[]
|
||||
/** 封面图 URL */
|
||||
cover_url?: string;
|
||||
cover_url?: string
|
||||
}
|
||||
|
||||
/** BGM 预设列表查询参数 */
|
||||
export interface BgmPresetsQuery {
|
||||
category?: BgmCategory | string;
|
||||
keyword?: string;
|
||||
category?: BgmCategory | string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** BGM 混音配置(嵌入剪辑计划) */
|
||||
export interface BgmMixConfig {
|
||||
/** 是否启用 BGM */
|
||||
enabled: boolean;
|
||||
enabled: boolean
|
||||
/** 选中的 BGM ID */
|
||||
music_id: string;
|
||||
music_id: string
|
||||
/** BGM 音量 0-100 */
|
||||
volume: number;
|
||||
volume: number
|
||||
/** 淡入时长(秒) 0-3 */
|
||||
fade_in: number;
|
||||
fade_in: number
|
||||
/** 淡出时长(秒) 0-3 */
|
||||
fade_out: number;
|
||||
fade_out: number
|
||||
/** 人声闪避(sidechain) */
|
||||
voice_dodge: boolean;
|
||||
voice_dodge: boolean
|
||||
}
|
||||
|
||||
/** 默认 BGM 混音配置 */
|
||||
@@ -54,17 +54,15 @@ export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||
fade_in: 0.5,
|
||||
fade_out: 0.5,
|
||||
voice_dodge: true,
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────── API ──────────── */
|
||||
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (params?.category) searchParams.category = params.category;
|
||||
if (params?.keyword) searchParams.keyword = params.keyword;
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams });
|
||||
return res.data?.data ?? res.data ?? [];
|
||||
};
|
||||
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {}
|
||||
if (params?.category) searchParams.category = params.category
|
||||
if (params?.keyword) searchParams.keyword = params.keyword
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams })
|
||||
return res.data?.data ?? res.data ?? []
|
||||
}
|
||||
|
||||
+78
-89
@@ -2,10 +2,10 @@
|
||||
* API 客户端配置
|
||||
* 封装 Axios 实例,配置拦截器和 Token 管理
|
||||
*/
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { message } from "antd";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { refreshAccessToken } from "./auth";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./auth"
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -14,161 +14,150 @@ const apiClient = axios.create({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// ── Token 刷新队列 ─────────────────────────────────────────
|
||||
let isRefreshing = false;
|
||||
let isRefreshing = false
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}> = [];
|
||||
resolve: (value?: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}> = []
|
||||
|
||||
const processQueue = (error: unknown | null, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
prom.reject(error)
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
prom.resolve(token)
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
// 请求拦截器:添加 Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(error)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (
|
||||
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
|
||||
) => {
|
||||
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
_retry?: boolean
|
||||
}
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken;
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
|
||||
// 无 refresh_token → 直接登出
|
||||
if (!refreshToken) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(error);
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 已在刷新中 → 排队等待
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
failedQueue.push({ resolve, reject })
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshToken);
|
||||
const newAccessToken = data.access_token;
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken;
|
||||
const data = await refreshAccessToken(refreshToken)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken
|
||||
|
||||
// 更新 Zustand + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(
|
||||
useAuthStore.getState().user!,
|
||||
newAccessToken,
|
||||
newRefreshToken,
|
||||
);
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken);
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// 重试原始请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败 → 登出
|
||||
processQueue(refreshError, null);
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(refreshError);
|
||||
processQueue(refreshError, null)
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提取后端返回的错误信息(detail / message / msg)
|
||||
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
|
||||
const data = error.response?.data;
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg;
|
||||
const data = error.response?.data
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
// 嵌套对象:递归提取
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return safeExtractString(obj.message);
|
||||
if (typeof obj.msg === "object" && obj.msg !== null)
|
||||
return safeExtractString(obj.msg);
|
||||
return JSON.stringify(val);
|
||||
return safeExtractString(obj.message)
|
||||
if (typeof obj.msg === "object" && obj.msg !== null) return safeExtractString(obj.msg)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const serverMsg = safeExtractString(rawServerMsg);
|
||||
let handled = false;
|
||||
return ""
|
||||
}
|
||||
const serverMsg = safeExtractString(rawServerMsg)
|
||||
let handled = false
|
||||
|
||||
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
|
||||
message.error("请求超时,请检查网络后重试");
|
||||
handled = true;
|
||||
message.error("请求超时,请检查网络后重试")
|
||||
handled = true
|
||||
} else if (!error.response) {
|
||||
message.error("网络连接异常,请检查网络设置");
|
||||
handled = true;
|
||||
message.error("网络连接异常,请检查网络设置")
|
||||
handled = true
|
||||
} else if (serverMsg) {
|
||||
message.error(serverMsg);
|
||||
handled = true;
|
||||
message.error(serverMsg)
|
||||
handled = true
|
||||
} else {
|
||||
const status = error.response?.status;
|
||||
const status = error.response?.status
|
||||
if (status === 413) {
|
||||
message.error("文件过大,请缩小后重试");
|
||||
handled = true;
|
||||
message.error("文件过大,请缩小后重试")
|
||||
handled = true
|
||||
} else if (status === 415) {
|
||||
message.error("不支持的文件格式");
|
||||
handled = true;
|
||||
message.error("不支持的文件格式")
|
||||
handled = true
|
||||
} else if (status === 503) {
|
||||
message.error("服务暂不可用,请稍后再试");
|
||||
handled = true;
|
||||
message.error("服务暂不可用,请稍后再试")
|
||||
handled = true
|
||||
} else if (status && status >= 500) {
|
||||
message.error("服务器繁忙,请稍后再试");
|
||||
handled = true;
|
||||
message.error("服务器繁忙,请稍后再试")
|
||||
handled = true
|
||||
}
|
||||
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
|
||||
}
|
||||
@@ -176,11 +165,11 @@ apiClient.interceptors.response.use(
|
||||
// 标记已展示过提示,组件 onError 可据此跳过重复 toast
|
||||
if (handled) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).__msgShown = true;
|
||||
;(error as any).__msgShown = true
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(error)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export default apiClient;
|
||||
export default apiClient
|
||||
|
||||
@@ -2,109 +2,98 @@
|
||||
* 查重 API 模块
|
||||
* 提供视频查重相关接口
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 查重记录状态 */
|
||||
export type DuplicationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
export type DuplicationStatus = "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/** 查重记录 */
|
||||
export interface DuplicationRecord {
|
||||
id: string;
|
||||
id: string
|
||||
/** 原始文件名 */
|
||||
filename: string;
|
||||
filename: string
|
||||
/** 文件大小(字节) */
|
||||
file_size: number;
|
||||
file_size: number
|
||||
/** 时长(秒) */
|
||||
duration_seconds?: number;
|
||||
duration_seconds?: number
|
||||
/** 状态 */
|
||||
status: DuplicationStatus;
|
||||
status: DuplicationStatus
|
||||
/** 查重率(0-100) */
|
||||
duplicate_rate?: number;
|
||||
duplicate_rate?: number
|
||||
/** 重复片段数 */
|
||||
duplicate_count?: number;
|
||||
duplicate_count?: number
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
created_at: string
|
||||
/** 更新时间 */
|
||||
updated_at: string;
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 重复片段详情 */
|
||||
export interface DuplicateSegment {
|
||||
id: string;
|
||||
id: string
|
||||
/** 在原始视频中的起始时间(秒) */
|
||||
source_start: number;
|
||||
source_start: number
|
||||
/** 在原始视频中的结束时间(秒) */
|
||||
source_end: number;
|
||||
source_end: number
|
||||
/** 匹配到的已有视频 ID */
|
||||
matched_video_id: string;
|
||||
matched_video_id: string
|
||||
/** 匹配到的已有视频名称 */
|
||||
matched_video_name: string;
|
||||
matched_video_name: string
|
||||
/** 匹配片段在已有视频中的起始时间 */
|
||||
matched_start: number;
|
||||
matched_start: number
|
||||
/** 匹配片段在已有视频中的结束时间 */
|
||||
matched_end: number;
|
||||
matched_end: number
|
||||
/** 相似度(0-100) */
|
||||
similarity: number;
|
||||
similarity: number
|
||||
}
|
||||
|
||||
/** 查重详情 */
|
||||
export interface DuplicationDetail extends DuplicationRecord {
|
||||
/** 重复片段列表 */
|
||||
segments: DuplicateSegment[];
|
||||
segments: DuplicateSegment[]
|
||||
}
|
||||
|
||||
/** 上传查重响应 */
|
||||
export interface DuplicationUploadResponse {
|
||||
/** 查重记录 ID */
|
||||
id: string;
|
||||
id: string
|
||||
/** 状态 */
|
||||
status: DuplicationStatus;
|
||||
status: DuplicationStatus
|
||||
/** 消息 */
|
||||
message: string;
|
||||
message: string
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 上传视频进行查重 */
|
||||
export const uploadForDuplication = async (
|
||||
file: File,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
const response = await apiClient.post("/duplication/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取查重记录列表 */
|
||||
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
||||
const response = await apiClient.get("/duplication/records");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get("/duplication/records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取查重详情 */
|
||||
export const getDuplicationDetail = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`);
|
||||
return response.data;
|
||||
};
|
||||
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除查重记录 */
|
||||
export const deleteDuplicationRecord = async (
|
||||
recordId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`);
|
||||
};
|
||||
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`)
|
||||
}
|
||||
|
||||
/** 重新查重 */
|
||||
export const retryDuplication = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(
|
||||
`/duplication/records/${recordId}/retry`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
+297
-342
@@ -2,8 +2,8 @@
|
||||
* 剪辑计划 API — 对接后端 Edit Plans Schema
|
||||
* 字段名严格匹配后端 API 响应
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { AssetItem } from "./assets";
|
||||
import apiClient from "./client"
|
||||
import type { AssetItem } from "./assets"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/* ============================================================
|
||||
* 后端 API 类型(严格匹配后端 Schema)
|
||||
@@ -20,203 +20,203 @@ import type {
|
||||
|
||||
/** 剪辑计划状态枚举 */
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled";
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 片段 TTS 配置 */
|
||||
export interface SegmentTtsConfig {
|
||||
mode: string;
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed: number;
|
||||
pitch: number;
|
||||
volume: number;
|
||||
subtitle_sync: boolean;
|
||||
mode: string
|
||||
text: string
|
||||
voice_id: string
|
||||
speed: number
|
||||
pitch: number
|
||||
volume: number
|
||||
subtitle_sync: boolean
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
export interface SegmentTrimConfig {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
/** 片段转场配置 */
|
||||
export interface SegmentTransitionConfig {
|
||||
type: string;
|
||||
duration: number;
|
||||
type: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||
export interface EditPlanSegment {
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string;
|
||||
transition?: SegmentTransitionConfig;
|
||||
playback_speed?: number;
|
||||
tts_config?: SegmentTtsConfig;
|
||||
trim_config?: SegmentTrimConfig;
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string
|
||||
transition?: SegmentTransitionConfig
|
||||
playback_speed?: number
|
||||
tts_config?: SegmentTtsConfig
|
||||
trim_config?: SegmentTrimConfig
|
||||
}
|
||||
|
||||
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||
export interface EditPlanConfig {
|
||||
title_config?: TitleConfig;
|
||||
subtitle_config?: SubtitleConfig;
|
||||
bgm_config?: BgmConfig;
|
||||
estimated_duration?: number;
|
||||
segments?: EditPlanSegment[];
|
||||
watermark_config?: WatermarkConfig;
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
pip_config?: PipConfig;
|
||||
filter_config?: FilterConfig;
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
sticker_config?: StickerConfig;
|
||||
cover_config?: CoverConfig;
|
||||
title_config?: TitleConfig
|
||||
subtitle_config?: SubtitleConfig
|
||||
bgm_config?: BgmConfig
|
||||
estimated_duration?: number
|
||||
segments?: EditPlanSegment[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
/** 前端扩展:关联的素材 ID 列表 */
|
||||
asset_ids?: string[];
|
||||
asset_ids?: string[]
|
||||
/** 配音 ID */
|
||||
voice_id?: string;
|
||||
voice_id?: string
|
||||
/** 克隆音色档案 ID */
|
||||
voice_clone_profile_id?: string;
|
||||
voice_clone_profile_id?: string
|
||||
/** 自定义配音音频 URL */
|
||||
custom_audio_url?: string;
|
||||
custom_audio_url?: string
|
||||
/** 自定义配音文本 */
|
||||
custom_text?: string;
|
||||
custom_text?: string
|
||||
/** 视频比例 */
|
||||
ratio?: string;
|
||||
ratio?: string
|
||||
/** 视频风格 */
|
||||
style?: string;
|
||||
style?: string
|
||||
/** 目标时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 是否自动生成字幕 */
|
||||
auto_subtitles?: boolean;
|
||||
auto_subtitles?: boolean
|
||||
/** 是否启用 BGM */
|
||||
bgm?: boolean;
|
||||
bgm?: boolean
|
||||
/** 生成数量 */
|
||||
generate_count?: number;
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string;
|
||||
material_mode?: string
|
||||
}
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
id: string;
|
||||
template_id: string;
|
||||
name: string;
|
||||
status: EditPlanStatus;
|
||||
total_duration: number;
|
||||
id: string
|
||||
template_id: string
|
||||
name: string
|
||||
status: EditPlanStatus
|
||||
total_duration: number
|
||||
/** 生成视频数量(后端 EditPlanResponse.result_count) */
|
||||
result_count: number;
|
||||
config: EditPlanConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
result_count: number
|
||||
config: EditPlanConfig
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
|
||||
export interface CreateEditPlanRequest {
|
||||
template_id: string;
|
||||
name: string;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
template_id: string
|
||||
name: string
|
||||
config?: EditPlanConfig
|
||||
total_duration?: number
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string;
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 更新剪辑计划请求 */
|
||||
export interface UpdateEditPlanRequest {
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
status?: EditPlanStatus;
|
||||
name?: string
|
||||
config?: EditPlanConfig
|
||||
total_duration?: number
|
||||
status?: EditPlanStatus
|
||||
}
|
||||
|
||||
/** 生成响应 */
|
||||
export interface GenerateResponse {
|
||||
plan_id: string;
|
||||
plan_status: EditPlanStatus;
|
||||
generation_task_id: string;
|
||||
clip_count: number;
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id: string
|
||||
clip_count: number
|
||||
}
|
||||
|
||||
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
|
||||
export interface EditPlanGeneration {
|
||||
id: string; // 即 generation_task_id
|
||||
source_edit_plan_id: string;
|
||||
template_id: string;
|
||||
asset_ids: string[];
|
||||
status: EditPlanStatus;
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message: string;
|
||||
error_info: Record<string, unknown>;
|
||||
logs: Array<Record<string, unknown>>;
|
||||
retry_count: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string // 即 generation_task_id
|
||||
source_edit_plan_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
status: EditPlanStatus
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
error_info: Record<string, unknown>
|
||||
logs: Array<Record<string, unknown>>
|
||||
retry_count: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 片段生成状态 */
|
||||
export interface ClipStatusItem {
|
||||
clip_id: string;
|
||||
clip_type: string;
|
||||
order: number;
|
||||
status: string;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
duration?: number;
|
||||
clip_id: string
|
||||
clip_type: string
|
||||
order: number
|
||||
status: string
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** 生成状态轮询响应 */
|
||||
export interface GenerationStatusResponse {
|
||||
plan_id: string;
|
||||
plan_status: EditPlanStatus;
|
||||
generation_task_id?: string;
|
||||
clips: ClipStatusItem[];
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
clips: ClipStatusItem[]
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id?: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
duration?: number;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
project_id?: string
|
||||
generation_task_id?: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size?: number
|
||||
duration?: number
|
||||
thumbnail_url?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
status: string
|
||||
review_status?: string
|
||||
download_url?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -225,51 +225,51 @@ export interface GeneratedVideo {
|
||||
|
||||
/** AI 推荐请求 */
|
||||
export interface AIRecommendRequest {
|
||||
asset_ids: string[];
|
||||
editing_mode?: string;
|
||||
target_duration?: number;
|
||||
asset_ids: string[]
|
||||
editing_mode?: string
|
||||
target_duration?: number
|
||||
}
|
||||
|
||||
/** AI 推荐单个片段 */
|
||||
export interface AIRecommendClipItem {
|
||||
clip_type: string;
|
||||
order: number;
|
||||
text_content: string;
|
||||
duration: number;
|
||||
transition_effect: string;
|
||||
asset_id: string;
|
||||
start_time: number;
|
||||
config: EditPlanConfig;
|
||||
clip_type: string
|
||||
order: number
|
||||
text_content: string
|
||||
duration: number
|
||||
transition_effect: string
|
||||
asset_id: string
|
||||
start_time: number
|
||||
config: EditPlanConfig
|
||||
}
|
||||
|
||||
/** AI 推荐响应 */
|
||||
export interface AIRecommendResponse {
|
||||
plan_id: string;
|
||||
clips: AIRecommendClipItem[];
|
||||
config: EditPlanConfig;
|
||||
total_duration: number;
|
||||
confidence: number;
|
||||
plan_id: string
|
||||
clips: AIRecommendClipItem[]
|
||||
config: EditPlanConfig
|
||||
total_duration: number
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** AI 封面生成请求 */
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[];
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate";
|
||||
frame_time?: number;
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string;
|
||||
cover: CoverResult;
|
||||
plan_id: string
|
||||
cover: CoverResult
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -293,30 +293,30 @@ export interface TransitionEffect {
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
duration: number; // 转场时长(秒)
|
||||
| "rectcrop"
|
||||
duration: number // 转场时长(秒)
|
||||
/** 播放速度倍率 */
|
||||
playback_speed?: number;
|
||||
playback_speed?: number
|
||||
}
|
||||
|
||||
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "video" | "image" | "audio";
|
||||
id: string
|
||||
name: string
|
||||
type: "video" | "image" | "audio"
|
||||
/** 缩略图 URL */
|
||||
thumbnail_url?: string;
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),仅 video/audio */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 文件大小(字节) */
|
||||
size?: number;
|
||||
size?: number
|
||||
/** 标签 */
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
tags: string[]
|
||||
created_at: string
|
||||
/** 质量分 0-100 */
|
||||
quality_score?: number;
|
||||
quality_score?: number
|
||||
/** 分类状态 */
|
||||
classification_status?: "pending" | "processing" | "completed" | "failed";
|
||||
classification_status?: "pending" | "processing" | "completed" | "failed"
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -325,42 +325,38 @@ export interface MediaAsset {
|
||||
|
||||
/** 剪辑计划列表查询参数 */
|
||||
export interface EditPlanListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
template_id?: string;
|
||||
status?: string;
|
||||
page?: number
|
||||
page_size?: number
|
||||
template_id?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** 剪辑计划列表分页响应 */
|
||||
export interface EditPlanListResponse {
|
||||
items: EditPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: EditPlan[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||
export async function getEditPlans(
|
||||
params?: EditPlanListParams,
|
||||
): Promise<EditPlanListResponse> {
|
||||
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个剪辑计划 */
|
||||
export async function getEditPlan(planId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建剪辑计划 */
|
||||
export async function createEditPlan(
|
||||
data: CreateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/edit-plans", data);
|
||||
return response.data;
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/edit-plans", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新剪辑计划 */
|
||||
@@ -368,31 +364,25 @@ export async function updateEditPlan(
|
||||
planId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/edit-plans/${planId}`, data);
|
||||
return response.data;
|
||||
const response = await apiClient.put(`/edit-plans/${planId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除剪辑计划 */
|
||||
export async function deleteEditPlan(planId: string): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}`);
|
||||
await apiClient.delete(`/edit-plans/${planId}`)
|
||||
}
|
||||
|
||||
/** 触发剪辑计划生成 */
|
||||
export async function generateEditPlan(
|
||||
planId: string,
|
||||
): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate`);
|
||||
return response.data;
|
||||
export async function generateEditPlan(planId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取剪辑计划生成状态(轮询用) */
|
||||
export async function getGenerationStatus(
|
||||
planId: string,
|
||||
): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(
|
||||
`/edit-plans/${planId}/generation-status`,
|
||||
);
|
||||
return response.data;
|
||||
export async function getGenerationStatus(planId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
@@ -400,11 +390,8 @@ export async function aiRecommendClips(
|
||||
planId: string,
|
||||
data: AIRecommendRequest,
|
||||
): Promise<AIRecommendResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/edit-plans/${planId}/ai-recommend`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
@@ -412,32 +399,25 @@ export async function generateCover(
|
||||
planId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/edit-plans/${planId}/generate-cover`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate-cover`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取剪辑计划关联的生成记录 */
|
||||
export async function getEditPlanGenerations(
|
||||
planId: string,
|
||||
): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generations`);
|
||||
return response.data.items || [];
|
||||
export async function getEditPlanGenerations(planId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(
|
||||
taskId: string,
|
||||
): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items || response.data || [];
|
||||
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(planId: string): Promise<void> {
|
||||
await apiClient.post(`/edit-plans/${planId}/cancel`);
|
||||
await apiClient.post(`/edit-plans/${planId}/cancel`)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -445,66 +425,66 @@ export async function cancelGeneration(planId: string): Promise<void> {
|
||||
* ============================================================ */
|
||||
|
||||
/** 片段状态 */
|
||||
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed";
|
||||
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
|
||||
|
||||
/** 剪辑片段(后端响应) */
|
||||
export interface EditPlanClip {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
clip_type: string; // main / intro / outro / overlay / background / b_roll 等
|
||||
order: number;
|
||||
asset_id: string;
|
||||
text_content: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
transition_effect: string;
|
||||
transition_duration: number;
|
||||
playback_speed: number;
|
||||
status: EditPlanClipStatus;
|
||||
config: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
plan_id: string
|
||||
clip_type: string // main / intro / outro / overlay / background / b_roll 等
|
||||
order: number
|
||||
asset_id: string
|
||||
text_content: string
|
||||
start_time: number
|
||||
duration: number
|
||||
transition_effect: string
|
||||
transition_duration: number
|
||||
playback_speed: number
|
||||
status: EditPlanClipStatus
|
||||
config: Record<string, unknown>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 创建片段请求 */
|
||||
export interface CreateEditPlanClipRequest {
|
||||
clip_type: string;
|
||||
order: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
clip_type: string
|
||||
order: number
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
start_time?: number
|
||||
duration?: number
|
||||
transition_effect?: string
|
||||
transition_duration?: number
|
||||
playback_speed?: number
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 更新片段请求 */
|
||||
export interface UpdateEditPlanClipRequest {
|
||||
clip_type?: string;
|
||||
order?: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
clip_type?: string
|
||||
order?: number
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
start_time?: number
|
||||
duration?: number
|
||||
transition_effect?: string
|
||||
transition_duration?: number
|
||||
playback_speed?: number
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 片段列表响应 */
|
||||
export interface EditPlanClipListResponse {
|
||||
items: EditPlanClip[];
|
||||
total: number;
|
||||
items: EditPlanClip[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 片段列表查询参数 */
|
||||
export interface EditPlanClipListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 获取片段列表 */
|
||||
@@ -512,22 +492,16 @@ export async function getEditPlanClips(
|
||||
planId: string,
|
||||
params?: EditPlanClipListParams,
|
||||
): Promise<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(`/edit-plans/${planId}/clips`, {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
);
|
||||
return response.data;
|
||||
export async function getEditPlanClip(planId: string, clipId: string): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
@@ -535,11 +509,8 @@ export async function createEditPlanClip(
|
||||
planId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post<EditPlanClip>(`/edit-plans/${planId}/clips`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
@@ -548,19 +519,13 @@ export async function updateEditPlanClip(
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.put<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`);
|
||||
export async function deleteEditPlanClip(planId: string, clipId: string): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -569,30 +534,30 @@ export async function deleteEditPlanClip(
|
||||
|
||||
/** 重排序条目 */
|
||||
export interface ClipReorderItem {
|
||||
clip_id: string;
|
||||
new_order: number;
|
||||
clip_id: string
|
||||
new_order: number
|
||||
}
|
||||
|
||||
/** 重排序响应 */
|
||||
export interface ClipReorderResponse {
|
||||
success: boolean;
|
||||
updated_count: number;
|
||||
message: string;
|
||||
success: boolean
|
||||
updated_count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 批量删除响应 */
|
||||
export interface ClipBatchDeleteResponse {
|
||||
success: boolean;
|
||||
deleted_count: number;
|
||||
message: string;
|
||||
success: boolean
|
||||
deleted_count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 从素材批量创建响应 */
|
||||
export interface ClipsFromAssetsResponse {
|
||||
success: boolean;
|
||||
created_count: number;
|
||||
message: string;
|
||||
clip_ids: string[];
|
||||
success: boolean
|
||||
created_count: number
|
||||
message: string
|
||||
clip_ids: string[]
|
||||
}
|
||||
|
||||
/** 片段重排序(拖拽排序后一次性提交) */
|
||||
@@ -603,8 +568,8 @@ export async function reorderEditPlanClips(
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/edit-plans/${planId}/clips/reorder`,
|
||||
{ items },
|
||||
);
|
||||
return response.data;
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 批量删除片段 */
|
||||
@@ -615,8 +580,8 @@ export async function batchDeleteEditPlanClips(
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/edit-plans/${planId}/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
);
|
||||
return response.data;
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
@@ -628,8 +593,8 @@ export async function createClipsFromAssets(
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/edit-plans/${planId}/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
);
|
||||
return response.data;
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -638,40 +603,32 @@ export async function createClipsFromAssets(
|
||||
|
||||
/** 复制计划请求 */
|
||||
export interface CopyEditPlanRequest {
|
||||
name?: string;
|
||||
project_id?: string;
|
||||
name?: string
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
/** 复制剪辑计划(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
planId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/edit-plans/${planId}/copy`,
|
||||
data || {},
|
||||
);
|
||||
return response.data;
|
||||
export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(`/edit-plans/${planId}/copy`, data || {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
*/
|
||||
export async function getMediaAssets(
|
||||
libraryId?: string,
|
||||
): Promise<MediaAsset[]> {
|
||||
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: libraryId ? { library_id: libraryId } : undefined,
|
||||
});
|
||||
const items: AssetItem[] = response.data.items || [];
|
||||
return items.map(mapAssetToMediaAsset);
|
||||
})
|
||||
const items: AssetItem[] = response.data.items || []
|
||||
return items.map(mapAssetToMediaAsset)
|
||||
}
|
||||
|
||||
/** 获取单个素材 */
|
||||
export async function getMediaAsset(id: string): Promise<MediaAsset> {
|
||||
const response = await apiClient.get(`/assets/${id}`);
|
||||
return mapAssetToMediaAsset(response.data);
|
||||
const response = await apiClient.get(`/assets/${id}`)
|
||||
return mapAssetToMediaAsset(response.data)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -679,17 +636,15 @@ export async function getMediaAsset(id: string): Promise<MediaAsset> {
|
||||
* ============================================================ */
|
||||
|
||||
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
return "audio";
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("image/")) return "image"
|
||||
return "audio"
|
||||
}
|
||||
|
||||
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
// 优先取顶层 duration,其次从 metadata 回退
|
||||
const metaDuration =
|
||||
typeof asset.metadata?.duration === "number"
|
||||
? asset.metadata.duration
|
||||
: undefined;
|
||||
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
@@ -701,7 +656,7 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
created_at: asset.created_at ?? "",
|
||||
quality_score: asset.quality_score ?? undefined,
|
||||
classification_status: asset.classification_status ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -710,9 +665,9 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
|
||||
/** 转场效果选项(14 种预设) */
|
||||
export const TRANSITION_OPTIONS: {
|
||||
value: TransitionEffect["type"];
|
||||
label: string;
|
||||
icon: string;
|
||||
value: TransitionEffect["type"]
|
||||
label: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{ value: "none", label: "无转场", icon: "⊘" },
|
||||
{ value: "cut", label: "硬切", icon: "✂" },
|
||||
@@ -729,7 +684,7 @@ export const TRANSITION_OPTIONS: {
|
||||
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
|
||||
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
|
||||
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
|
||||
];
|
||||
]
|
||||
|
||||
/** 素材类型标签 */
|
||||
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -737,7 +692,7 @@ export const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
};
|
||||
}
|
||||
|
||||
/** 素材类型图标 */
|
||||
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
|
||||
@@ -745,7 +700,7 @@ export const MATERIAL_TYPE_ICONS: Record<string, string> = {
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
};
|
||||
}
|
||||
|
||||
/** 计划状态标签 */
|
||||
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
@@ -755,17 +710,17 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
}
|
||||
|
||||
/** 质量分筛选选项 */
|
||||
export const QUALITY_OPTIONS: {
|
||||
value: string;
|
||||
label: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
value: string
|
||||
label: string
|
||||
min?: number
|
||||
max?: number
|
||||
}[] = [
|
||||
{ value: "all", label: "全部质量" },
|
||||
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
|
||||
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
|
||||
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
|
||||
];
|
||||
]
|
||||
|
||||
+104
-112
@@ -2,7 +2,7 @@
|
||||
* 剪辑计划编辑器 API
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
@@ -11,12 +11,12 @@ import type {
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip";
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
@@ -24,7 +24,7 @@ export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
};
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
@@ -32,211 +32,203 @@ export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
};
|
||||
}
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: Omit<TemplateSegment, "id">[];
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number;
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string;
|
||||
field?: string
|
||||
/** 期望值 */
|
||||
expected?: string | number;
|
||||
expected?: string | number
|
||||
/** 实际值 */
|
||||
actual?: string | number;
|
||||
actual?: string | number
|
||||
/** 建议值 */
|
||||
suggested?: string | number;
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: ValidationWarningDetails;
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate;
|
||||
warnings: ValidateWarning[];
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[];
|
||||
total: number;
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[];
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string;
|
||||
tag?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
});
|
||||
let list = response.data.items;
|
||||
if (params?.category)
|
||||
list = list.filter((t) => t.category === params.category);
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
|
||||
return list;
|
||||
};
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (
|
||||
id: string,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(
|
||||
`/templates/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`);
|
||||
};
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>(
|
||||
"/templates/categories/list",
|
||||
);
|
||||
return response.data.items;
|
||||
};
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
@@ -246,6 +238,6 @@ export const generateFromTemplate = async (
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
Executable → Regular
+58
-70
@@ -3,59 +3,59 @@
|
||||
* 包含:列表查询、复核状态、批量下载
|
||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getGenerationTaskResults } from "./editPlans";
|
||||
import type { GeneratedVideo } from "./editPlans";
|
||||
import apiClient from "./client"
|
||||
import { getGenerationTaskResults } from "./editPlans"
|
||||
import type { GeneratedVideo } from "./editPlans"
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
title: string;
|
||||
video_url?: string;
|
||||
thumbnail_url?: string;
|
||||
duration_seconds?: number;
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus;
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string;
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string;
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
project_id?: string;
|
||||
review_status?: ReviewStatus | "all";
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string;
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number;
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,8 +69,7 @@ function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||
thumbnail_url: task.thumbnail_url,
|
||||
duration_seconds: task.duration,
|
||||
file_size: task.file_size,
|
||||
resolution:
|
||||
task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
resolution: task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
status:
|
||||
task.status === "completed"
|
||||
? "completed"
|
||||
@@ -81,43 +80,36 @@ function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||
project_id: task.project_id,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/generation/tasks", { params });
|
||||
const data = response.data;
|
||||
const tasks = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
return tasks.map(mapTaskToProductItem);
|
||||
};
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/generation/tasks", { params })
|
||||
const tasks = response.data.items || response.data || []
|
||||
return tasks.map(mapTaskToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 — 通过 task ID 获取结果 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||
return mapTaskToProductItem(response.data);
|
||||
};
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`)
|
||||
return mapTaskToProductItem(response.data)
|
||||
}
|
||||
|
||||
/** 删除成品 — 删除 generation task */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||
};
|
||||
await apiClient.delete(`/generation/tasks/${productId}`)
|
||||
}
|
||||
|
||||
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const videos = await getGenerationTaskResults(productId);
|
||||
const video = videos[0];
|
||||
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||
return { url: video.download_url, expires_at: "" };
|
||||
};
|
||||
const videos = await getGenerationTaskResults(productId)
|
||||
const video = videos[0]
|
||||
if (!video?.download_url) throw new Error("下载链接不可用")
|
||||
return { url: video.download_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
@@ -126,26 +118,22 @@ export const updateReviewStatus = async (
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId);
|
||||
return { ...product, review_status: status };
|
||||
};
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /generation/tasks/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||
return { job_id: `mock-${Date.now()}` };
|
||||
};
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||
return { job_id: jobId, status: "processing", progress: 0 };
|
||||
};
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
|
||||
@@ -2,60 +2,59 @@
|
||||
* 项目相关 API
|
||||
* 素材库需要 project_id,前端自动管理默认项目
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 后端 ProjectResponse 只返回 id, name, description */
|
||||
interface BackendProjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 后端 ListProjectsResponse 返回 { items: [...] } */
|
||||
interface BackendListProjectsResponse {
|
||||
items: BackendProjectResponse[];
|
||||
items: BackendProjectResponse[]
|
||||
}
|
||||
|
||||
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
});
|
||||
})
|
||||
|
||||
/** 获取当前用户的项目列表 */
|
||||
export const getProjects = async (): Promise<ProjectItem[]> => {
|
||||
const response =
|
||||
await apiClient.get<BackendListProjectsResponse>("/projects");
|
||||
return (response.data.items || []).map(toProjectItem);
|
||||
};
|
||||
const response = await apiClient.get<BackendListProjectsResponse>("/projects")
|
||||
return (response.data.items || []).map(toProjectItem)
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
export const createProject = async (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
name: string
|
||||
description?: string
|
||||
}): Promise<ProjectItem> => {
|
||||
const response = await apiClient.post<BackendProjectResponse>("/projects", {
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
});
|
||||
return toProjectItem(response.data);
|
||||
};
|
||||
})
|
||||
return toProjectItem(response.data)
|
||||
}
|
||||
|
||||
/** 获取或创建默认项目(素材库需要 project_id) */
|
||||
export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
|
||||
const projects = await getProjects();
|
||||
const projects = await getProjects()
|
||||
if (projects.length > 0) {
|
||||
return projects[0];
|
||||
return projects[0]
|
||||
}
|
||||
// 没有项目时自动创建默认项目
|
||||
return createProject({
|
||||
name: "默认项目",
|
||||
description: "系统自动创建的默认项目",
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,100 +2,98 @@
|
||||
* 订阅 API 模块
|
||||
* 对接后端订阅管理接口
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 套餐类型 */
|
||||
export type PlanType = "free" | "standard" | "pro" | "enterprise";
|
||||
export type PlanType = "free" | "standard" | "pro" | "enterprise"
|
||||
|
||||
/** 订阅状态 */
|
||||
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial";
|
||||
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"
|
||||
|
||||
/** 账单状态 */
|
||||
export type BillingStatus = "paid" | "pending" | "failed" | "refunded";
|
||||
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
|
||||
|
||||
/** 计费周期 */
|
||||
export type BillingCycle = "monthly" | "yearly";
|
||||
export type BillingCycle = "monthly" | "yearly"
|
||||
|
||||
/** 套餐信息 */
|
||||
export interface Plan {
|
||||
id: PlanType;
|
||||
name: string;
|
||||
price: number | null;
|
||||
yearly_price?: number | null;
|
||||
description: string;
|
||||
recommended: boolean;
|
||||
features: string[];
|
||||
id: PlanType
|
||||
name: string
|
||||
price: number | null
|
||||
yearly_price?: number | null
|
||||
description: string
|
||||
recommended: boolean
|
||||
features: string[]
|
||||
}
|
||||
|
||||
/** 当前订阅信息 */
|
||||
export interface SubscriptionInfo {
|
||||
id: string;
|
||||
plan_id: PlanType;
|
||||
plan_name: string;
|
||||
status: SubscriptionStatus;
|
||||
billing_cycle: BillingCycle;
|
||||
current_period_start: string;
|
||||
current_period_end: string;
|
||||
amount: number;
|
||||
auto_renew: boolean;
|
||||
created_at: string;
|
||||
id: string
|
||||
plan_id: PlanType
|
||||
plan_name: string
|
||||
status: SubscriptionStatus
|
||||
billing_cycle: BillingCycle
|
||||
current_period_start: string
|
||||
current_period_end: string
|
||||
amount: number
|
||||
auto_renew: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 账单记录 */
|
||||
export interface BillingRecord {
|
||||
id: string;
|
||||
plan_name: string;
|
||||
amount: number;
|
||||
billing_cycle: BillingCycle;
|
||||
status: BillingStatus;
|
||||
payment_method: string;
|
||||
created_at: string;
|
||||
invoice_url?: string;
|
||||
id: string
|
||||
plan_name: string
|
||||
amount: number
|
||||
billing_cycle: BillingCycle
|
||||
status: BillingStatus
|
||||
payment_method: string
|
||||
created_at: string
|
||||
invoice_url?: string
|
||||
}
|
||||
|
||||
/** 升级/降级请求 */
|
||||
export interface ChangePlanRequest {
|
||||
target_plan_id: PlanType;
|
||||
billing_cycle: BillingCycle;
|
||||
target_plan_id: PlanType
|
||||
billing_cycle: BillingCycle
|
||||
}
|
||||
|
||||
/** 升级/降级响应 */
|
||||
export interface ChangePlanResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
new_subscription?: SubscriptionInfo;
|
||||
success: boolean
|
||||
message: string
|
||||
new_subscription?: SubscriptionInfo
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取当前订阅信息 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
const response = await apiClient.get("/subscription/current");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get("/subscription/current")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取账单记录列表 */
|
||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||
const response = await apiClient.get("/subscription/billing-records");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get("/subscription/billing-records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 升级/降级套餐 */
|
||||
export const changePlan = async (
|
||||
request: ChangePlanRequest,
|
||||
): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post("/subscription/change-plan", request);
|
||||
return response.data;
|
||||
};
|
||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post("/subscription/change-plan", request)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 取消订阅 */
|
||||
export const cancelSubscription = async (): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
success: boolean
|
||||
message: string
|
||||
}> => {
|
||||
const response = await apiClient.post("/subscription/cancel");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/subscription/cancel")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 切换自动续费 */
|
||||
export const toggleAutoRenew = async (
|
||||
@@ -103,6 +101,6 @@ export const toggleAutoRenew = async (
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post("/subscription/toggle-auto-renew", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
+20
-26
@@ -2,45 +2,39 @@
|
||||
* 标签 CRUD API
|
||||
* P3 标签体系:对接后端标签表
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
export interface TagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
usage_count?: number;
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
usage_count?: number
|
||||
}
|
||||
|
||||
/** 获取当前用户所有标签 */
|
||||
export const getTags = async (): Promise<TagItem[]> => {
|
||||
const response = await apiClient.get("/tags");
|
||||
return response.data.items || [];
|
||||
};
|
||||
const response = await apiClient.get("/tags")
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建标签(同名返回 409) */
|
||||
export const createTag = async (name: string): Promise<TagItem> => {
|
||||
const response = await apiClient.post("/tags", { name });
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/tags", { name })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除标签(同时清理素材关联) */
|
||||
export const deleteTag = async (tagId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tags/${tagId}`);
|
||||
};
|
||||
await apiClient.delete(`/tags/${tagId}`)
|
||||
}
|
||||
|
||||
/** 为素材添加标签(最多 50 个) */
|
||||
export const tagAsset = async (
|
||||
assetId: string,
|
||||
tagIds: string[],
|
||||
): Promise<void> => {
|
||||
if (tagIds.length === 0) return;
|
||||
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds });
|
||||
};
|
||||
export const tagAsset = async (assetId: string, tagIds: string[]): Promise<void> => {
|
||||
if (tagIds.length === 0) return
|
||||
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds })
|
||||
}
|
||||
|
||||
/** 移除素材的某个标签 */
|
||||
export const untagAsset = async (
|
||||
assetId: string,
|
||||
tagId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
|
||||
};
|
||||
export const untagAsset = async (assetId: string, tagId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`)
|
||||
}
|
||||
|
||||
+63
-69
@@ -6,85 +6,84 @@
|
||||
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 任务状态 */
|
||||
export type TaskStatus =
|
||||
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
|
||||
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 任务类型 */
|
||||
export type TaskType = "ingest" | "generation" | string;
|
||||
export type TaskType = "ingest" | "generation" | string
|
||||
|
||||
/** 错误详情 */
|
||||
export interface TaskErrorInfo {
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
failed_step: string;
|
||||
stack_trace?: string;
|
||||
error_type: string
|
||||
error_message: string
|
||||
failed_step: string
|
||||
stack_trace?: string
|
||||
}
|
||||
|
||||
/** 任务条目(对应用户级 UserTaskResponse) */
|
||||
export interface TaskItem {
|
||||
id: string;
|
||||
task_type: TaskType;
|
||||
project_id: string;
|
||||
template_id?: string;
|
||||
status: TaskStatus;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
error_message: string;
|
||||
user_message: string;
|
||||
retryable: boolean;
|
||||
source_id: string;
|
||||
id: string
|
||||
task_type: TaskType
|
||||
project_id: string
|
||||
template_id?: string
|
||||
status: TaskStatus
|
||||
progress: number
|
||||
current_step: string
|
||||
error_message: string
|
||||
user_message: string
|
||||
retryable: boolean
|
||||
source_id: string
|
||||
/** 错误详情(失败任务) */
|
||||
error_info?: TaskErrorInfo;
|
||||
error_info?: TaskErrorInfo
|
||||
/** 耗时(秒) */
|
||||
duration_seconds?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
duration_seconds?: number
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 任务列表查询参数 */
|
||||
export interface TaskListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: TaskStatus | "all";
|
||||
task_type?: TaskType | "all";
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: TaskStatus | "all"
|
||||
task_type?: TaskType | "all"
|
||||
}
|
||||
|
||||
/** 任务列表分页响应 */
|
||||
export interface TaskListResponse {
|
||||
items: TaskItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TaskItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 创建生成任务请求参数 */
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string;
|
||||
asset_ids: string[];
|
||||
title_ids: string[];
|
||||
voice_ids: string[];
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id: string;
|
||||
voice_library_id: string;
|
||||
template_id: string;
|
||||
asset_ids: string[];
|
||||
title_ids: string[];
|
||||
voice_ids: string[];
|
||||
status: string;
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message: string;
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
strategy_id: string
|
||||
voice_library_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
status: string
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
@@ -93,37 +92,32 @@ export interface CreateGenerationTaskResponse {
|
||||
export const createGenerationTask = async (
|
||||
params: CreateGenerationTaskRequest,
|
||||
): Promise<CreateGenerationTaskResponse> => {
|
||||
const { data } = await apiClient.post<CreateGenerationTaskResponse>(
|
||||
"/generation/tasks",
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (
|
||||
params?: TaskListParams,
|
||||
): Promise<TaskListResponse> => {
|
||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks");
|
||||
return data.items || data || [];
|
||||
};
|
||||
const { data } = await apiClient.get("/tasks")
|
||||
return data.items || data || []
|
||||
}
|
||||
|
||||
/** 获取单个任务详情(含 error_info) */
|
||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`);
|
||||
return data;
|
||||
};
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 重试失败的任务 */
|
||||
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
|
||||
return data;
|
||||
};
|
||||
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -7,142 +7,132 @@
|
||||
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
|
||||
import type { EditPlanConfig } from "./editPlans";
|
||||
import apiClient from "./client"
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"
|
||||
import type { EditPlanConfig } from "./editPlans"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板条目(后端 TemplateResponse) */
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mode?: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
id: string
|
||||
user_id?: string
|
||||
name: string
|
||||
description?: string
|
||||
mode?: string
|
||||
category: string
|
||||
tags?: string[]
|
||||
/** 预估时长(后端字段名 estimated_duration) */
|
||||
estimated_duration?: number;
|
||||
estimated_duration?: number
|
||||
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
|
||||
target_duration?: number;
|
||||
clip_count?: number;
|
||||
target_duration?: number
|
||||
clip_count?: number
|
||||
/** 使用次数 */
|
||||
usage_count?: number;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
is_active?: boolean;
|
||||
is_favorite?: boolean;
|
||||
usage_count?: number
|
||||
thumbnail_url?: string
|
||||
preview_url?: string
|
||||
is_active?: boolean
|
||||
is_favorite?: boolean
|
||||
/** 素材规则(片段配置) */
|
||||
segments?: TemplateSegment[];
|
||||
segments?: TemplateSegment[]
|
||||
/** 字幕样式 */
|
||||
subtitle_config?: SubtitleConfig;
|
||||
subtitle_config?: SubtitleConfig
|
||||
/** BGM 配置 */
|
||||
bgm_config?: BgmConfig;
|
||||
bgm_config?: BgmConfig
|
||||
/** 标题配置 */
|
||||
title_config?: TitleConfig;
|
||||
title_config?: TitleConfig
|
||||
/** 视频比例 */
|
||||
aspect_ratio?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
aspect_ratio?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 模板片段(素材规则) */
|
||||
export interface TemplateSegment {
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
description?: string;
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** 模板列表查询参数 */
|
||||
export interface TemplateListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
keyword?: string;
|
||||
page?: number
|
||||
page_size?: number
|
||||
category?: string
|
||||
tags?: string
|
||||
keyword?: string
|
||||
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
|
||||
duration_range?: "short" | "medium" | "long";
|
||||
duration_range?: "short" | "medium" | "long"
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
export interface TemplateListResponse {
|
||||
items: TemplateItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TemplateItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划请求 */
|
||||
export interface GenerateFromTemplateRequest {
|
||||
asset_ids?: string[];
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
asset_ids?: string[]
|
||||
name?: string
|
||||
config?: EditPlanConfig
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
plan_id: string;
|
||||
template_id: string;
|
||||
status: string;
|
||||
name: string;
|
||||
plan_id: string
|
||||
template_id: string
|
||||
status: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 复制模板响应 */
|
||||
export interface CopyTemplateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
source_template_id: string;
|
||||
id: string
|
||||
name: string
|
||||
source_template_id: string
|
||||
}
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
|
||||
/** 获取模板列表(支持分页和筛选) */
|
||||
export const getTemplates = async (
|
||||
params?: TemplateListParams,
|
||||
): Promise<TemplateListResponse> => {
|
||||
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
|
||||
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get("/templates");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
const response = await apiClient.get("/templates")
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 获取单个模板详情 */
|
||||
export const getTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<TemplateItem> => {
|
||||
const response = await apiClient.get(`/templates/${templateId}`);
|
||||
return response.data;
|
||||
};
|
||||
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
|
||||
const response = await apiClient.get(`/templates/${templateId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 收藏 / 取消收藏模板 */
|
||||
export const toggleFavoriteTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<{ is_favorite: boolean }> => {
|
||||
const response = await apiClient.post(
|
||||
`/templates/${templateId}/toggle-favorite`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 复制模板(创建副本到我的模板) */
|
||||
export const copyTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(
|
||||
`/templates/${templateId}/copy`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划 */
|
||||
export const generateFromTemplate = async (
|
||||
@@ -152,9 +142,9 @@ export const generateFromTemplate = async (
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
@@ -167,7 +157,7 @@ export const TEMPLATE_CATEGORY_OPTIONS = [
|
||||
{ value: "品牌", label: "品牌" },
|
||||
{ value: "混剪", label: "混剪" },
|
||||
{ value: "Vlog", label: "Vlog" },
|
||||
];
|
||||
]
|
||||
|
||||
/** 时长筛选选项 */
|
||||
export const TEMPLATE_DURATION_OPTIONS = [
|
||||
@@ -175,4 +165,4 @@ export const TEMPLATE_DURATION_OPTIONS = [
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
]
|
||||
|
||||
+57
-69
@@ -3,51 +3,51 @@
|
||||
* Phase 1 新增:全局标题库
|
||||
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 标题条目(前端展示用) */
|
||||
export interface TitleItem {
|
||||
id: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
word_count?: number;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
content: string
|
||||
category?: string
|
||||
source?: string
|
||||
word_count?: number
|
||||
is_favorite?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 后端标题响应格式 */
|
||||
interface BackendTitleResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
category: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
usage_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
text: string
|
||||
category: string
|
||||
description: string
|
||||
tags: string[]
|
||||
usage_count: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 后端创建标题请求格式 */
|
||||
interface BackendCreateTitleRequest {
|
||||
name: string;
|
||||
text: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
name: string
|
||||
text: string
|
||||
category: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/** 后端更新标题请求格式 */
|
||||
interface BackendUpdateTitleRequest {
|
||||
name?: string;
|
||||
text?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
name?: string
|
||||
text?: string
|
||||
category?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/** 将后端响应映射为前端 TitleItem */
|
||||
@@ -58,73 +58,61 @@ const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
|
||||
word_count: item.text?.length || 0,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
});
|
||||
})
|
||||
|
||||
/** 创建标题请求(前端接口,保持向后兼容) */
|
||||
export interface CreateTitleRequest {
|
||||
content: string;
|
||||
category?: string;
|
||||
content: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有标题 */
|
||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
||||
const response = await apiClient.get<
|
||||
{ items: BackendTitleResponse[] } | BackendTitleResponse[]
|
||||
>("/titles");
|
||||
const response = await apiClient.get<{ items: BackendTitleResponse[] } | BackendTitleResponse[]>(
|
||||
"/titles",
|
||||
)
|
||||
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
|
||||
const items = Array.isArray(response.data)
|
||||
? response.data
|
||||
: response.data.items || [];
|
||||
return items.map(toTitleItem);
|
||||
};
|
||||
const items = Array.isArray(response.data) ? response.data : response.data.items || []
|
||||
return items.map(toTitleItem)
|
||||
}
|
||||
|
||||
/** 创建标题 */
|
||||
export const createTitle = async (
|
||||
data: CreateTitleRequest,
|
||||
): Promise<TitleItem> => {
|
||||
export const createTitle = async (data: CreateTitleRequest): Promise<TitleItem> => {
|
||||
// 后端要求 name(≤255)和 text(≤500),name 从 content 截取
|
||||
const payload: BackendCreateTitleRequest = {
|
||||
name: data.content.slice(0, 255),
|
||||
text: data.content.slice(0, 500),
|
||||
category: data.category || "default",
|
||||
};
|
||||
const response = await apiClient.post<BackendTitleResponse>(
|
||||
"/titles",
|
||||
payload,
|
||||
);
|
||||
return toTitleItem(response.data);
|
||||
};
|
||||
}
|
||||
const response = await apiClient.post<BackendTitleResponse>("/titles", payload)
|
||||
return toTitleItem(response.data)
|
||||
}
|
||||
|
||||
/** 更新标题 */
|
||||
export const updateTitle = async (
|
||||
titleId: string,
|
||||
data: Partial<CreateTitleRequest>,
|
||||
): Promise<TitleItem> => {
|
||||
const payload: BackendUpdateTitleRequest = {};
|
||||
const payload: BackendUpdateTitleRequest = {}
|
||||
if (data.content !== undefined) {
|
||||
payload.name = data.content.slice(0, 255);
|
||||
payload.text = data.content.slice(0, 500);
|
||||
payload.name = data.content.slice(0, 255)
|
||||
payload.text = data.content.slice(0, 500)
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
payload.category = data.category;
|
||||
payload.category = data.category
|
||||
}
|
||||
// 后端用 PUT,非 PATCH
|
||||
const response = await apiClient.put<BackendTitleResponse>(
|
||||
`/titles/${titleId}`,
|
||||
payload,
|
||||
);
|
||||
return toTitleItem(response.data);
|
||||
};
|
||||
const response = await apiClient.put<BackendTitleResponse>(`/titles/${titleId}`, payload)
|
||||
return toTitleItem(response.data)
|
||||
}
|
||||
|
||||
/** 删除标题 */
|
||||
export const deleteTitle = async (titleId: string): Promise<void> => {
|
||||
await apiClient.delete(`/titles/${titleId}`);
|
||||
};
|
||||
await apiClient.delete(`/titles/${titleId}`)
|
||||
}
|
||||
|
||||
/** 批量导入标题 */
|
||||
export const batchImportTitles = async (
|
||||
titles: string[],
|
||||
): Promise<{ imported_count: number }> => {
|
||||
const response = await apiClient.post("/titles/batch-import", { titles });
|
||||
return response.data;
|
||||
};
|
||||
export const batchImportTitles = async (titles: string[]): Promise<{ imported_count: number }> => {
|
||||
const response = await apiClient.post("/titles/batch-import", { titles })
|
||||
return response.data
|
||||
}
|
||||
|
||||
+95
-110
@@ -4,91 +4,91 @@
|
||||
*
|
||||
* 任务 3.14 新增
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 类型定义 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 元数据(合成时附带的扩展信息) */
|
||||
export interface TTSMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
sample_rate?: number
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
language?: string
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** TTS 合成请求参数 */
|
||||
export interface TTSSynthesizeRequest {
|
||||
text: string;
|
||||
voice_id?: string;
|
||||
output_name?: string;
|
||||
language?: string;
|
||||
speed?: number;
|
||||
voice_model?: string;
|
||||
voice_clone_profile_id?: string;
|
||||
format?: string;
|
||||
metadata?: TTSMetadata;
|
||||
text: string
|
||||
voice_id?: string
|
||||
output_name?: string
|
||||
language?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
format?: string
|
||||
metadata?: TTSMetadata
|
||||
}
|
||||
|
||||
/** TTS 合成创建响应 */
|
||||
export interface TTSSynthesizeResponse {
|
||||
job_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
job_id: string
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** TTS 任务详情 */
|
||||
export interface TTSJob {
|
||||
id: string;
|
||||
user_id: string;
|
||||
project_id: string | null;
|
||||
text: string;
|
||||
voice_id: string | null;
|
||||
voice_model: string | null;
|
||||
voice_clone_profile_id: string | null;
|
||||
language: string;
|
||||
speed: number;
|
||||
output_name: string | null;
|
||||
output_audio_url: string | null;
|
||||
output_format: string;
|
||||
duration_seconds: number | null;
|
||||
file_size_bytes: number | null;
|
||||
sample_rate: number | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: TTSMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string
|
||||
user_id: string
|
||||
project_id: string | null
|
||||
text: string
|
||||
voice_id: string | null
|
||||
voice_model: string | null
|
||||
voice_clone_profile_id: string | null
|
||||
language: string
|
||||
speed: number
|
||||
output_name: string | null
|
||||
output_audio_url: string | null
|
||||
output_format: string
|
||||
duration_seconds: number | null
|
||||
file_size_bytes: number | null
|
||||
sample_rate: number | null
|
||||
status: string
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: TTSMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** TTS 任务状态(轻量轮询用) */
|
||||
export interface TTSJobStatus {
|
||||
id: string;
|
||||
status: string;
|
||||
output_audio_url: string | null;
|
||||
error_message: string | null;
|
||||
duration_seconds: number | null;
|
||||
retry_count: number;
|
||||
id: string
|
||||
status: string
|
||||
output_audio_url: string | null
|
||||
error_message: string | null
|
||||
duration_seconds: number | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表响应 */
|
||||
export interface TTSJobListResponse {
|
||||
items: TTSJob[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
items: TTSJob[]
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表查询参数 */
|
||||
export interface TTSJobListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/* ── API 函数 ──────────────────────────────────── */
|
||||
@@ -97,47 +97,37 @@ export interface TTSJobListParams {
|
||||
export const synthesizeSpeech = async (
|
||||
data: TTSSynthesizeRequest,
|
||||
): Promise<TTSSynthesizeResponse> => {
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>(
|
||||
"/tts/synthesize",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务详情 */
|
||||
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务状态(轻量轮询) */
|
||||
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
|
||||
const response = await apiClient.get<TTSJobStatus>(
|
||||
`/tts/jobs/${jobId}/status`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务列表 */
|
||||
export const getTTSJobs = async (
|
||||
params?: TTSJobListParams,
|
||||
): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<TTSJobListResponse>(
|
||||
`/tts/jobs${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 存为素材请求参数 */
|
||||
export interface SaveTtsToLibraryRequest {
|
||||
name?: string;
|
||||
tag_ids?: string[];
|
||||
name?: string
|
||||
tag_ids?: string[]
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音素材库 */
|
||||
@@ -145,59 +135,54 @@ export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {});
|
||||
};
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
|
||||
}
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||
};
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/* ── 音色列表 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 音色 */
|
||||
export interface TTSVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string
|
||||
name: string
|
||||
/** 音色分类标签:male/female/young/service/news/emotion */
|
||||
category?: string;
|
||||
category?: string
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
language?: string
|
||||
/** 试听 URL */
|
||||
preview_url?: string;
|
||||
preview_url?: string
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── TTS 试听 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 试听请求参数 */
|
||||
export interface TTSPreviewRequest {
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed?: number;
|
||||
pitch?: number;
|
||||
text: string
|
||||
voice_id: string
|
||||
speed?: number
|
||||
pitch?: number
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
export interface TTSPreviewResponse {
|
||||
audio_url: string;
|
||||
duration?: number;
|
||||
audio_url: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (
|
||||
data: TTSPreviewRequest,
|
||||
): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>(
|
||||
"/tts/preview",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
+98
-123
@@ -3,35 +3,35 @@
|
||||
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
|
||||
* 任务 3.15:新增 progress 字段用于进度展示
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 前端兼容类型 ─────────────────────────────────────── */
|
||||
|
||||
/** 克隆音色状态(前端展示用) */
|
||||
export type VoiceCloneStatus = "ready" | "processing" | "failed";
|
||||
export type VoiceCloneStatus = "ready" | "processing" | "failed"
|
||||
|
||||
/** 克隆音色条目(前端展示用) */
|
||||
export interface VoiceClone {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration_seconds: number;
|
||||
status: VoiceCloneStatus;
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
duration_seconds: number
|
||||
status: VoiceCloneStatus
|
||||
/** 克隆进度 0-100,仅 processing 状态时有值 */
|
||||
progress: number;
|
||||
sample_url?: string;
|
||||
language: string;
|
||||
gender: string;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
progress: number
|
||||
sample_url?: string
|
||||
language: string
|
||||
gender: string
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建克隆请求(前端简化版) */
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string;
|
||||
audio_url: string;
|
||||
description?: string;
|
||||
name: string
|
||||
audio_url: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/* ── 后端 API 类型 ────────────────────────────────────── */
|
||||
@@ -39,60 +39,60 @@ export interface CreateVoiceCloneRequest {
|
||||
/** 音色克隆元数据(克隆时附带的扩展信息) */
|
||||
export interface VoiceCloneMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
sample_rate?: number
|
||||
/** 音色 ID(克隆完成后分配) */
|
||||
voice_id?: string;
|
||||
voice_id?: string
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 后端克隆档案响应 */
|
||||
export interface VoiceCloneProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source_audio_url: string;
|
||||
voice_id: string | null;
|
||||
voice_model: string;
|
||||
language: string;
|
||||
gender: string;
|
||||
status: "pending" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: VoiceCloneMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
description: string
|
||||
source_audio_url: string
|
||||
voice_id: string | null
|
||||
voice_model: string
|
||||
language: string
|
||||
gender: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: VoiceCloneMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 后端克隆列表响应 */
|
||||
export interface ListVoiceCloneResponse {
|
||||
items: VoiceCloneProfile[];
|
||||
total: number;
|
||||
items: VoiceCloneProfile[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 后端克隆状态响应 */
|
||||
export interface VoiceCloneStatusResponse {
|
||||
id: string;
|
||||
status: "pending" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
voice_id: string | null;
|
||||
retry_count: number;
|
||||
id: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
voice_id: string | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** 后端创建克隆请求(完整版) */
|
||||
export interface CreateVoiceCloneRequestFull {
|
||||
name: string;
|
||||
description?: string;
|
||||
source_audio_url: string;
|
||||
voice_model?: string;
|
||||
language?: string;
|
||||
gender?: string;
|
||||
max_retries?: number;
|
||||
metadata_?: VoiceCloneMetadata;
|
||||
name: string
|
||||
description?: string
|
||||
source_audio_url: string
|
||||
voice_model?: string
|
||||
language?: string
|
||||
gender?: string
|
||||
max_retries?: number
|
||||
metadata_?: VoiceCloneMetadata
|
||||
}
|
||||
|
||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||
@@ -114,66 +114,54 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
|
||||
error_message: profile.error_message || null,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
});
|
||||
})
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/* ── 查询参数 ─────────────────────────────────────────── */
|
||||
|
||||
export interface VoiceCloneListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/* ── API 函数 ─────────────────────────────────────────── */
|
||||
|
||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||
export const getVoiceClones = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(
|
||||
`/voice-clones${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data.items.map(toVoiceClone);
|
||||
};
|
||||
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data.items.map(toVoiceClone)
|
||||
}
|
||||
|
||||
/** 获取克隆音色列表(返回完整响应含 total) */
|
||||
export const getVoiceClonesWithTotal = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<ListVoiceCloneResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(
|
||||
`/voice-clones${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个克隆音色详情 */
|
||||
export const getVoiceCloneDetail = async (
|
||||
id: string,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
export const createVoiceClone = async (
|
||||
@@ -183,18 +171,15 @@ export const createVoiceClone = async (
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
};
|
||||
const response = await apiClient.post<VoiceCloneProfile>(
|
||||
"/voice-clones",
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除克隆音色 */
|
||||
export const deleteVoiceClone = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/voice-clones/${id}`);
|
||||
};
|
||||
await apiClient.delete(`/voice-clones/${id}`)
|
||||
}
|
||||
|
||||
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
|
||||
export const updateVoiceClone = async (
|
||||
@@ -202,32 +187,22 @@ export const updateVoiceClone = async (
|
||||
data: Partial<Pick<VoiceClone, "name">>,
|
||||
): Promise<VoiceClone> => {
|
||||
// 后端暂未提供更新端点,暂用详情接口模拟
|
||||
const response = await apiClient.get<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}`,
|
||||
);
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return toVoiceClone({
|
||||
...response.data,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取克隆状态 */
|
||||
export const getVoiceCloneStatus = async (
|
||||
id: string,
|
||||
): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(
|
||||
`/voice-clones/${id}/status`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 重试克隆 */
|
||||
export const retryVoiceClone = async (
|
||||
id: string,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}/retry`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
+81
-87
@@ -4,148 +4,142 @@
|
||||
*
|
||||
* 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 统一音色 API(后端 3.04) ─────────────────────────── */
|
||||
|
||||
/** 统一音色条目(preset + clone 混合) */
|
||||
export interface UnifiedVoiceItem {
|
||||
id: string;
|
||||
type: "preset" | "clone";
|
||||
name: string;
|
||||
description: string;
|
||||
gender: string;
|
||||
language: string;
|
||||
voice_id: string;
|
||||
voice_provider: string;
|
||||
audio_url: string | null;
|
||||
preview_url: string | null;
|
||||
duration: number | null;
|
||||
file_size: number | null;
|
||||
status: string;
|
||||
tags: string[];
|
||||
user_id: string | null;
|
||||
project_id: string | null;
|
||||
voice_clone_profile_id: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
id: string
|
||||
type: "preset" | "clone"
|
||||
name: string
|
||||
description: string
|
||||
gender: string
|
||||
language: string
|
||||
voice_id: string
|
||||
voice_provider: string
|
||||
audio_url: string | null
|
||||
preview_url: string | null
|
||||
duration: number | null
|
||||
file_size: number | null
|
||||
status: string
|
||||
tags: string[]
|
||||
user_id: string | null
|
||||
project_id: string | null
|
||||
voice_clone_profile_id: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/** 统一音色列表响应 */
|
||||
export interface UnifiedVoiceListResponse {
|
||||
items: UnifiedVoiceItem[];
|
||||
total: number;
|
||||
preset_count: number;
|
||||
clone_count: number;
|
||||
items: UnifiedVoiceItem[]
|
||||
total: number
|
||||
preset_count: number
|
||||
clone_count: number
|
||||
}
|
||||
|
||||
/** 预设音色条目 */
|
||||
export interface PresetVoiceItem {
|
||||
voice_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
gender: string;
|
||||
language: string;
|
||||
preview_url: string | null;
|
||||
tags: string[];
|
||||
voice_id: string
|
||||
name: string
|
||||
description: string
|
||||
gender: string
|
||||
language: string
|
||||
preview_url: string | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
/** 预设音色列表响应 */
|
||||
export interface PresetVoiceListResponse {
|
||||
items: PresetVoiceItem[];
|
||||
total: number;
|
||||
items: PresetVoiceItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 统一列表查询参数 */
|
||||
export interface UnifiedVoiceListParams {
|
||||
type?: "preset" | "clone";
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
type?: "preset" | "clone"
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 获取统一音色列表(推荐) */
|
||||
export const fetchVoices = async (
|
||||
params?: UnifiedVoiceListParams,
|
||||
): Promise<UnifiedVoiceListResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set("type", params.type);
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<UnifiedVoiceListResponse>(
|
||||
`/voices${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.type) searchParams.set("type", params.type)
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<UnifiedVoiceListResponse>(`/voices${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取预设音色列表(无需鉴权) */
|
||||
export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => {
|
||||
const response =
|
||||
await apiClient.get<PresetVoiceListResponse>("/voices/presets");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<PresetVoiceListResponse>("/voices/presets")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 向后兼容(旧接口) ────────────────────────────────── */
|
||||
|
||||
/** 配音条目(旧) */
|
||||
export interface VoiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
duration_seconds?: number;
|
||||
storage_key?: string;
|
||||
audio_url?: string;
|
||||
status?: string;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
name: string
|
||||
text: string
|
||||
voice_type?: string
|
||||
duration_seconds?: number
|
||||
storage_key?: string
|
||||
audio_url?: string
|
||||
status?: string
|
||||
is_favorite?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 创建配音请求(旧) */
|
||||
export interface CreateVoiceRequest {
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
name: string
|
||||
text: string
|
||||
voice_type?: string
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有配音(旧 → /voices/legacy) */
|
||||
export const getVoices = async (): Promise<VoiceItem[]> => {
|
||||
const response = await apiClient.get("/voices/legacy");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
const response = await apiClient.get("/voices/legacy")
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 创建配音 */
|
||||
export const createVoice = async (
|
||||
data: CreateVoiceRequest,
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post("/voices", data);
|
||||
return response.data;
|
||||
};
|
||||
export const createVoice = async (data: CreateVoiceRequest): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post("/voices", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新配音 */
|
||||
export const updateVoice = async (
|
||||
voiceId: string,
|
||||
data: Partial<CreateVoiceRequest>,
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.put(`/voices/${voiceId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.put(`/voices/${voiceId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除配音 */
|
||||
export const deleteVoice = async (voiceId: string): Promise<void> => {
|
||||
await apiClient.delete(`/voices/${voiceId}`);
|
||||
};
|
||||
await apiClient.delete(`/voices/${voiceId}`)
|
||||
}
|
||||
|
||||
/** AI 生成配音 */
|
||||
export const generateAIVoice = async (data: {
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
speed?: number;
|
||||
text: string
|
||||
voice_type?: string
|
||||
speed?: number
|
||||
}): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post("/voices/generate", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/voices/generate", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user