From 878bd35cf4e6d02d07016e8ebe7eff13bc0c9f7f Mon Sep 17 00:00:00 2001 From: hzw Date: Fri, 18 Sep 2026 23:13:32 +0800 Subject: [PATCH] fix(create): prevent duplicate rapid submissions --- web/src/pages/create/creation-submit-gate.ts | 14 ++++++++++++ web/src/pages/create/index.tsx | 23 +++++++++++++++++++- web/test/creation-submit-gate.test.ts | 14 ++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 web/src/pages/create/creation-submit-gate.ts create mode 100644 web/test/creation-submit-gate.test.ts diff --git a/web/src/pages/create/creation-submit-gate.ts b/web/src/pages/create/creation-submit-gate.ts new file mode 100644 index 000000000..02c5b83bc --- /dev/null +++ b/web/src/pages/create/creation-submit-gate.ts @@ -0,0 +1,14 @@ +export function createCreationSubmitGate() { + let locked = false; + + return { + tryAcquire() { + if (locked) return false; + locked = true; + return true; + }, + release() { + locked = false; + }, + }; +} diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 3a0ab6e3e..3a8f4758e 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -33,6 +33,7 @@ import { defaultCreationMode, modeLabels, type CreationConversation, type Creati import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations"; import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace"; import { CreationAgentEntry } from "./creation-agent-entry"; +import { createCreationSubmitGate } from "./creation-submit-gate"; const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal }))); const loadCreationRuntime = () => import("./creation-runtime"); @@ -113,6 +114,7 @@ export default function CreatePage() { const taskSyncWarningRef = useRef(false); const activeGenerationTaskIdsRef = useRef(new Set()); const retryPreparingRef = useRef(new Set()); + const submitGateRef = useRef(createCreationSubmitGate()); const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null); const [retrySequence, setRetrySequence] = useState(0); const [composerPreferencesInitialized, setComposerPreferencesInitialized] = useState(false); @@ -527,24 +529,33 @@ export default function CreatePage() { const releaseRetryLock = () => { if (retryLockKey) retryPreparingRef.current.delete(retryLockKey); }; + if (!submitGateRef.current.tryAcquire()) { + releaseRetryLock(); + return; + } + const releaseSubmitGate = () => submitGateRef.current.release(); const text = prompt.trim(); if (!text || busy || !activeConversation) { releaseRetryLock(); + releaseSubmitGate(); return; } if (!selectedModel) { toast.warning(`请先在设置中配置${modeLabels[mode]}模型`); releaseRetryLock(); + releaseSubmitGate(); return; } if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) { toast.error("当前模型不支持所选视频时长,请重新选择"); releaseRetryLock(); + releaseSubmitGate(); return; } if (attachments.length > maxReferences) { toast.warning("参考内容正在按当前模型能力调整,请稍后重试"); releaseRetryLock(); + releaseSubmitGate(); return; } const settings = { ratio, seconds, quality, videoQuality, count }; @@ -559,7 +570,15 @@ export default function CreatePage() { characterCount: 0, }); const skillReferences = references.flatMap((reference) => (reference.skill ? [reference.skill] : [])); - const runtime = await loadCreationRuntime(); + let runtime: CreationRuntime; + try { + runtime = await loadCreationRuntime(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "生成运行时加载失败"); + releaseRetryLock(); + releaseSubmitGate(); + return; + } let skillExecution: Awaited>; try { skillExecution = await runtime.skillRuntime.prepare({ @@ -571,6 +590,7 @@ export default function CreatePage() { } catch (error) { toast.error(error instanceof Error ? error.message : "技能上下文加载失败"); releaseRetryLock(); + releaseSubmitGate(); return; } const expandedPrompt = skillExecution.prompt; @@ -719,6 +739,7 @@ export default function CreatePage() { for (const taskId of boundTaskIds) activeGenerationTaskIdsRef.current.delete(taskId); requestLifecycle.release(); releaseRetryLock(); + releaseSubmitGate(); if (abortRef.current === controller) { abortRef.current = null; setBusy(false); diff --git a/web/test/creation-submit-gate.test.ts b/web/test/creation-submit-gate.test.ts new file mode 100644 index 000000000..44e36e5c7 --- /dev/null +++ b/web/test/creation-submit-gate.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; + +import { createCreationSubmitGate } from "../src/pages/create/creation-submit-gate"; + +test("creation submit gate rejects a second submit until the first submit releases it", () => { + const gate = createCreationSubmitGate(); + + expect(gate.tryAcquire()).toBe(true); + expect(gate.tryAcquire()).toBe(false); + + gate.release(); + + expect(gate.tryAcquire()).toBe(true); +});