ci: Prettier纳入两层防御体系 (#520)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1437h19m24s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1437h19m26s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1437h19m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1437h19m26s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1437h21m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1437h51m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped

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:
2026-07-18 18:08:19 +08:00
committed by auto-approve-bot
parent 1ee779a566
commit 0fcb77b991
145 changed files with 10310 additions and 12869 deletions
+219 -242
View File
@@ -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>)
}