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:
+145
-16
@@ -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;
|
||||
}, "成片已导出")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user