feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying

Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-09 14:46:42 +08:00
co-authored by Claude Opus 4.8
parent 92826dec14
commit 0873e724bf
29 changed files with 2393 additions and 423 deletions
+145 -16
View File
@@ -1,18 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
Asset,
BillingSummary,
BillingTrend,
ExportPoll,
Ledger,
LoginSession,
ModelConfig,
Notification,
Product,
Project,
Team,
TeamMember,
User
User,
UserPreference
} from "./types";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
import {
@@ -41,7 +45,7 @@ const crumbLabels: Partial<Record<Page, string>> = {
dashboard: "工作台",
products: "商品库",
productDetail: "商品详情",
productCreateUpload: "新建商品",
productCreateUpload: "商品库",
projects: "视频项目",
projectWizard: "新建视频项目",
pipeline: "生产管线",
@@ -76,9 +80,13 @@ export function App() {
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null);
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [billingTrend, setBillingTrend] = useState<BillingTrend | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
const [preferences, setPreferences] = useState<UserPreference | null>(null);
const [sessions, setSessions] = useState<LoginSession[]>([]);
const [activeProductId, setActiveProductId] = useState(route.productId || "");
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
@@ -95,13 +103,14 @@ export function App() {
);
const loadData = useCallback(async () => {
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData, notificationData] =
const [productData, projectData, assetData, billingData, ledgerData, trendData, memberData, modelData, taskData, notificationData] =
await Promise.all([
api.products(),
api.projects(),
api.assets(),
api.billingSummary().catch(() => null),
api.ledgers().catch(() => []),
api.billingTrend().catch(() => null),
api.teamMembers().catch(() => []),
api.modelConfigs().catch(() => null),
api.aiTasks().catch(() => null),
@@ -115,6 +124,7 @@ export function App() {
setAiTasks(taskData?.results || []);
if (billingData) setBilling(billingData);
setLedgers(ledgerData);
setBillingTrend(trendData);
if (notificationData) {
setNotifications(notificationData.results);
setUnreadCount(notificationData.unread_count);
@@ -123,6 +133,33 @@ export function App() {
setActiveProductId((current) => current || productData.results[0]?.id || "");
}, []);
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
const loadSettingsData = useCallback(async () => {
const [pref, sess] = await Promise.all([
api.preferences().catch(() => null),
api.loginSessions().catch(() => [])
]);
if (pref) setPreferences(pref);
setSessions(sess);
}, []);
async function savePreferences(payload: Partial<UserPreference>) {
const next = await api.updatePreferences(payload).catch(() => null);
if (next) setPreferences(next);
return next;
}
async function revokeSession(id: string) {
await action(() => api.revokeSession(id), "设备已下线");
setSessions(await api.loginSessions().catch(() => []));
}
async function revokeOtherSessions() {
const res = await action(() => api.revokeOtherSessions(), "其他设备已全部下线");
if (res?.token) setToken(res.token);
setSessions(await api.loginSessions().catch(() => []));
}
const reloadNotifications = useCallback(async () => {
const data = await api.listNotifications().catch(() => null);
if (data) {
@@ -170,6 +207,12 @@ export function App() {
return () => window.removeEventListener("popstate", syncRouteFromHistory);
}, []);
// Load preferences + sessions when entering settings.
useEffect(() => {
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
loadSettingsData();
}, [authed, page, loadSettingsData]);
// Load full project detail when entering the pipeline.
useEffect(() => {
if (!authed || page !== "pipeline" || !activeProjectId) {
@@ -177,6 +220,7 @@ export function App() {
return;
}
let cancelled = false;
setExportResult(null); // 切项目/进管线时清空上个项目的导出态
api
.project(activeProjectId)
.then((detail) => {
@@ -188,6 +232,30 @@ export function App() {
};
}, [authed, page, activeProjectId]);
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
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) {
setProjectDetail(detail);
return;
}
for (const segment of active) {
await api.pollVideo(activeProjectId, segment.id).catch(() => undefined);
}
const next = await api.project(activeProjectId).catch(() => null);
if (next) setProjectDetail(next);
}, [activeProjectId]);
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
const refreshExport = useCallback(async () => {
if (!activeProjectId) return;
const res = await api.pollExport(activeProjectId).catch(() => null);
if (res) setExportResult(res);
}, [activeProjectId]);
function navigate(next: Page, options: NavigateOptions = {}) {
const productId = options.productId ?? activeProductId;
const projectId = options.projectId ?? activeProjectId;
@@ -209,7 +277,16 @@ export function App() {
if (detail) setProjectDetail(detail);
}
// 防重复提交:已有操作在途时,后续 action 直接忽略(双击/连点/未及时置灰的按钮都安全)。
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
const actionInFlightRef = useRef(false);
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
if (actionInFlightRef.current) {
setNotice({ type: "error", text: "操作进行中,请稍候…" });
return null;
}
actionInFlightRef.current = true;
setLoading(true);
setNotice(null);
try {
@@ -223,6 +300,7 @@ export function App() {
return null;
} finally {
setLoading(false);
actionInFlightRef.current = false;
}
}
@@ -254,6 +332,11 @@ export function App() {
if (res) setUser(res);
}
async function resetOwnAvatar() {
const res = await action(() => api.resetAvatar(), "已恢复默认头像");
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
return action(() => api.generateImage(payload), "图片已生成");
}
@@ -322,23 +405,31 @@ export function App() {
return (
<ProductsPage
products={products}
projects={projects}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
/>
);
case "productCreateUpload":
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
return (
<ProductCreateUploadPage
<ProductsPage
products={products}
projects={projects}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={async (payload) => {
const created = await action(() => api.createProduct(payload), "商品已创建");
if (created) navigate("productDetail", { productId: created.id });
}}
onBack={() => navigate("products")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
autoOpenCreate
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />;
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
return (
<ProductDetailPage
product={activeProduct}
@@ -346,6 +437,9 @@ export function App() {
assets={assets}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
onUploadImage={(formData) => action(() => api.uploadProductImage(activeProduct.id, formData), "商品图已上传")}
onDeleteImage={(imageId) => action(() => api.deleteProductImage(activeProduct.id, imageId), "商品图已移除")}
onGenerateImages={generateImages}
/>
);
case "projects":
@@ -388,12 +482,13 @@ export function App() {
</div>
);
case "library":
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "account":
return (
<AccountPage
billing={billing}
ledgers={ledgers}
trend={billingTrend}
projects={projects}
teamMembers={teamMembers}
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
@@ -406,6 +501,7 @@ export function App() {
user={currentUser}
members={teamMembers}
billing={billing}
notifications={notifications}
navigate={navigate}
onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")}
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
@@ -433,13 +529,13 @@ export function App() {
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
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 })} />;
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
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 })} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
@@ -470,9 +566,21 @@ export function App() {
onGenerateScript={(prompt) => action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已生成")}
onGenerateStoryboard={(prompt) =>
action(async () => {
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待
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("故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
return true;
}, "故事板已生成")
}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")}
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 () => {
@@ -484,8 +592,9 @@ export function App() {
});
}
return targets.length;
}, "60s 多段视频任务已提交")
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
onPollAllVideos={() =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
@@ -495,7 +604,27 @@ export function App() {
return targets.length;
}, "视频片段状态已刷新")
}
onSubmitExport={() => action(() => api.submitExport(pipelineProject.id), "导出任务已提交")}
exportResult={exportResult}
onRefreshExport={refreshExport}
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), "草稿已保存")}
onSubmitExport={(payload) =>
action(async () => {
// 导出前先落盘当前编辑态(片段/字幕/转场/BGM),成片即所见
if (payload) await api.saveTimeline(pipelineProject.id, payload);
await api.submitExport(pipelineProject.id);
// 后端在后台线程跑 ffmpeg 拼接,这里轮询 poll-export 直到成片/失败,实时回填进度
for (let i = 0; i < 160; i += 1) {
const res = await api.pollExport(pipelineProject.id);
setExportResult(res);
if (res.status === "succeeded") return res;
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
return null;
}, "成片已导出")
}
/>
);
}