后端生成闸+多项修复;前端全站更新;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
+48 -30
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, getToken, setToken } from "./api";
import { api, ApiError, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
@@ -177,12 +177,24 @@ export function App() {
let cancelled = false;
(async () => {
try {
const identity = await api.me();
if (cancelled) return;
// 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token
let identity: Awaited<ReturnType<typeof api.me>> | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
identity = await api.me();
break;
} catch (error) {
const status = error instanceof ApiError ? error.status : 0;
if (status === 401 || status === 403 || attempt === 2) throw error;
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}
if (cancelled || !identity) return;
setUser(identity.user);
setTeam(identity.team);
await loadData();
} catch {
} catch (bootError) {
console.error("[boot] failed:", bootError);
setToken(null);
if (!cancelled) setAuthed(false);
} finally {
@@ -233,18 +245,23 @@ export function App() {
}, [authed, page, activeProjectId]);
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
// 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行
// ARK 轮询(总耗时随段数线性涨)。现用内存态定位在途段(省前置 GET),段间 Promise.all 并行,一轮只回读一次。
const projectDetailRef = useRef<Project | null>(null);
useEffect(() => {
projectDetailRef.current = projectDetail;
}, [projectDetail]);
const pollVideosQuiet = useCallback(async () => {
if (!activeProjectId) return;
const detail = await api.project(activeProjectId).catch(() => null);
if (!detail) return;
const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
if (active.length === 0) {
let detail = projectDetailRef.current;
if (!detail || detail.id !== activeProjectId) {
detail = await api.project(activeProjectId).catch(() => null);
if (!detail) return;
setProjectDetail(detail);
return;
}
for (const segment of active) {
await api.pollVideo(activeProjectId, segment.id).catch(() => undefined);
}
const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
if (active.length === 0) return;
await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined)));
const next = await api.project(activeProjectId).catch(() => null);
if (next) setProjectDetail(next);
}, [activeProjectId]);
@@ -457,6 +474,7 @@ export function App() {
return (
<ProjectWizardPage
products={products}
projects={projects}
onBack={() => navigate("projects")}
onCreate={async (payload) => {
const created = await action(() => api.createProject(payload), "项目已创建");
@@ -532,9 +550,9 @@ export function App() {
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} />;
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} />;
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
@@ -546,9 +564,11 @@ export function App() {
// 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell
const pipelineProject = projectDetail || activeProject;
if (page === "pipeline" && pipelineProject) {
const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
return (
<PipelinePage
project={pipelineProject}
scriptModelName={textModel?.display_name || textModel?.name || "AI"}
loading={loading}
navigate={navigate}
user={currentUser}
@@ -561,9 +581,13 @@ export function App() {
unreadCount={unreadCount}
avatarChar={avatarChar}
logout={logout}
onRefresh={refreshProjectDetail}
onGenerateScript={(prompt) => action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")}
onGenerateScript={(prompt, source) => action(() => api.generateScript(pipelineProject.id, { prompt, source }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")}
onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")}
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")}
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) =>
action(async () => {
@@ -571,16 +595,17 @@ export function App() {
await api.generateStoryboard(pipelineProject.id, { prompt });
for (let i = 0; i < 60; i += 1) {
const res = await api.pollStoryboard(pipelineProject.id);
if (res.status === "succeeded") break;
if (res.status === "failed") throw new Error("故事板生成失败,请重试");
if (res.status === "succeeded") return true;
if (res.status === "failed") throw new Error(res.error || "故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
return true;
// 轮询窗口耗尽仍未完成:如实报超时,不能让 action 弹「已生成」的假成功 toast
throw new Error("故事板生成超时,请稍后刷新查看或重试");
}, "故事板已生成")
}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onPollVideo={(segmentId) => action(() => api.pollVideo(pipelineProject.id, segmentId), "片段状态已刷新")}
onSubmitAllVideos={(prompt) =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
@@ -594,17 +619,9 @@ export function App() {
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
onPollAllVideos={() =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
for (const segment of targets) {
await api.pollVideo(pipelineProject.id, segment.id).catch(() => undefined);
}
return targets.length;
}, "视频片段状态已刷新")
}
exportResult={exportResult}
onRefreshExport={refreshExport}
onRefreshProject={refreshProjectDetail}
onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")}
onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")}
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
@@ -621,7 +638,8 @@ export function App() {
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
return null;
// 轮询窗口耗尽:如实报超时(后台 ffmpeg 可能仍在跑,进入拼接页会自动回填)
throw new Error("导出超时,后台可能仍在拼接,稍后回到本页查看");
}, "成片已导出")
}
/>