后端生成闸+多项修复;前端全站更新;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:
+48
-30
@@ -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("导出超时,后台可能仍在拼接,稍后回到本页查看");
|
||||
}, "成片已导出")
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user