diff --git a/admin-frontend/src/components/CreateStoryModal.vue b/admin-frontend/src/components/CreateStoryModal.vue index e4ead09..5110a81 100644 --- a/admin-frontend/src/components/CreateStoryModal.vue +++ b/admin-frontend/src/components/CreateStoryModal.vue @@ -40,10 +40,13 @@ const userStore = useUserStore() // State const inputType = ref<'keywords' | 'full_story'>('keywords') const outputMode = ref<'full_story' | 'storybook'>('full_story') -const inputData = ref('') -const educationTheme = ref('') -const loading = ref(false) -const error = ref('') +const inputData = ref('') +const educationTheme = ref('') +const loading = ref(false) +const canceling = ref(false) +const cancelRequested = ref(false) +const error = ref('') +const activeGenerationJobId = ref(null) // Data interface ChildProfile { @@ -110,10 +113,17 @@ interface GenerationAcceptedResponse { interface GenerationJobDetail { story_id: number | null + status: string + current_step: string is_terminal: boolean error_message: string | null } +interface GenerationJobActionResponse { + status: string + current_step: string +} + const JOB_POLL_INTERVAL_MS = 1500 const JOB_POLL_MAX_ATTEMPTS = 80 @@ -121,6 +131,9 @@ const JOB_POLL_MAX_ATTEMPTS = 80 function close() { emit('update:modelValue', false) error.value = '' + activeGenerationJobId.value = null + cancelRequested.value = false + canceling.value = false } function sleep(ms: number) { @@ -132,6 +145,9 @@ function sleep(ms: number) { async function waitForStoryId(jobId: string) { for (let attempt = 0; attempt < JOB_POLL_MAX_ATTEMPTS; attempt += 1) { const detail = await api.get(`/api/generations/jobs/${jobId}`) + if (detail.status === 'canceled' || detail.current_step === 'generation_canceled') { + return null + } if (detail.story_id) { return detail.story_id } @@ -143,6 +159,27 @@ async function waitForStoryId(jobId: string) { throw new Error('任务已提交,但主内容落库超时,请稍后到故事库查看最新结果') } + +async function cancelGenerationJob() { + if (!activeGenerationJobId.value || canceling.value || cancelRequested.value) return + + canceling.value = true + error.value = '' + try { + const result = await api.post( + `/api/generations/jobs/${activeGenerationJobId.value}/cancel`, + ) + cancelRequested.value = true + if (result.status === 'canceled' || result.current_step === 'generation_canceled') { + loading.value = false + close() + } + } catch (e) { + error.value = e instanceof Error ? e.message : '取消任务失败' + } finally { + canceling.value = false + } +} async function fetchProfiles() { if (!userStore.user) return @@ -191,7 +228,9 @@ async function generateStory() { return } - loading.value = true + loading.value = true + cancelRequested.value = false + activeGenerationJobId.value = null error.value = '' try { @@ -211,8 +250,13 @@ async function generateStory() { if (!jobId) { throw new Error('生成任务已创建,但缺少任务编号') } + activeGenerationJobId.value = jobId const storyId = accepted.id ?? await waitForStoryId(jobId) + if (storyId === null) { + close() + return + } close() if (requestedOutputMode.value === 'storybook') { router.push(`/storybook/view/${storyId}`) @@ -222,9 +266,11 @@ async function generateStory() { } catch (e) { error.value = e instanceof Error ? e.message : '生成失败' } finally { - loading.value = false - } -} + loading.value = false + activeGenerationJobId.value = null + cancelRequested.value = false + } +}