后端生成闸+多项修复;前端全站更新;QA 审计与报告

后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-15 10:06:15 +08:00
co-authored by Claude Fable 5
parent 890cb9ab67
commit 216a711291
92 changed files with 5114 additions and 2027 deletions
+38 -3
View File
@@ -17,7 +17,8 @@ import type {
Team,
TeamMember,
User,
UserPreference
UserPreference,
VoiceoverInfo
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
@@ -53,7 +54,17 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, text || `${response.status} ${response.statusText}`);
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
let message = text || `${response.status} ${response.statusText}`;
try {
const data = JSON.parse(text) as Record<string, unknown>;
const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0];
if (typeof first === "string") message = first;
else if (Array.isArray(first) && typeof first[0] === "string") message = first[0];
} catch {
/* 非 JSON(如网关 HTML)保持原文 */
}
throw new ApiError(response.status, message);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
@@ -168,7 +179,7 @@ export const api = {
deleteProject(id: string) {
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
},
generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) {
generateScript(projectId: string, payload: { prompt: string; source?: string; selling_point_ids?: string[] }) {
return request<ScriptVersion>(`/api/projects/${projectId}/generate-script/`, {
method: "POST",
body: JSON.stringify(payload)
@@ -180,6 +191,21 @@ export const api = {
body: JSON.stringify({ script_version_id })
});
},
updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
addScriptSegment(projectId: string, payload: { after_segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/add-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
deleteScriptSegment(projectId: string, payload: { segment_id: string }) {
return request<ScriptVersion>(`/api/projects/${projectId}/delete-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
adoptVideoVersion(projectId: string, payload: { video_segment_id: string; version_id: string }) {
return request<Project>(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) });
},
generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) {
return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) });
},
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) {
return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
@@ -231,6 +257,15 @@ export const api = {
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
// 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源)
async fetchAssetBlob(id: string): Promise<Blob> {
const token = getToken();
const response = await fetch(`${API_BASE}/api/assets/${id}/raw/`, {
headers: token ? { Authorization: `Token ${token}` } : undefined
});
if (!response.ok) throw new ApiError(response.status, `fetch asset raw failed: ${response.status}`);
return response.blob();
},
// 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url,
// 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。
async allAssets(): Promise<Asset[]> {