diff --git a/README.md b/README.md index 4c76b5a..9b33194 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - MiniMax H3 视频及 VDN-H3 2K/4K 高清版本,保留原版供对比。 - 角色、场景、道具的图片参考图上传与编辑接口。 - 本地长任务持续查询、重启后恢复查询和原任务结果恢复。 +- 任务列表可单独取消排队或生成中的本地图片、视频任务,保留已有素材和历史记录。 - 固定数据目录、一键启动、升级备份与真人实拍、仙侠风格预设。 - 跨集结尾上下文、分镜起止状态、前镜头尾帧接续与持久化依赖队列。 @@ -34,6 +35,12 @@ 服务器的进度扩展源文件为 `integration-work/comfy_progress.py`,由 `install_comfy_progress.py` 备份后嵌入现有适配器;无需重启 ComfyUI。图片、视频及私有配置仍不进入 Git。 +## 取消任务 + +打开右上角「任务」,在对应图片或视频记录上点击「取消任务」。只停止这一条任务,不影响其他镜头、其他显卡任务或已有素材。等待前镜头的任务也可以直接取消。 + +已提交的任务需要收到对应节点的取消确认,才会显示「已取消」;未收到回执时显示「正在等待显卡确认」,可重试取消,重启后仍会跟踪原任务。提交中尚无任务编号、已经完成或正在保存结果的任务不提供取消按钮。已取消任务单独统计,不计入失败数量。 + ## 目录 | 目录或文件 | 用途 | diff --git a/huobao-source/backend/src/routes/episodes.ts b/huobao-source/backend/src/routes/episodes.ts index a8d838f..46642a9 100644 --- a/huobao-source/backend/src/routes/episodes.ts +++ b/huobao-source/backend/src/routes/episodes.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { taskRouting } from '../services/worker-routing.js' +import { canCancelGenerationTask } from '../services/generation.js' import { and, eq, isNull } from 'drizzle-orm' import { db, getInsertId, schema } from '../db/index.js' import { success, notFound, badRequest, now } from '../utils/response.js' @@ -260,7 +261,7 @@ app.get('/:id/generation-tasks', async (c) => { .slice(0, 20) return success(c, { - tasks: toSnakeCaseArray(tasks.map(task => ({ ...task, routing: taskRouting(task) }))), + tasks: toSnakeCaseArray(tasks.map(task => ({ ...task, routing: taskRouting(task), canCancel: canCancelGenerationTask(task) }))), merges: toSnakeCaseArray(merges), }) }) diff --git a/huobao-source/backend/src/routes/tasks.ts b/huobao-source/backend/src/routes/tasks.ts index 7041940..d08770b 100644 --- a/huobao-source/backend/src/routes/tasks.ts +++ b/huobao-source/backend/src/routes/tasks.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono' import { and, desc, eq } from 'drizzle-orm' import { db, schema } from '../db/index.js' import { success, created, badRequest } from '../utils/response.js' -import { generateImage, generateVideo, recoverGenerationTask, cancelWaitingVideo } from '../services/generation.js' +import { generateImage, generateVideo, recoverGenerationTask, cancelWaitingVideo, cancelGenerationTask, canCancelGenerationTask } from '../services/generation.js' import { safeTaskError } from '../services/video-download.js' import { taskRouting } from '../services/worker-routing.js' import { getActiveConfig, getConfigById } from '../services/ai.js' @@ -12,6 +12,11 @@ import { logTaskError, logTaskPayload, logTaskStart, logTaskSuccess } from '../u const app = new Hono() +app.post('/:id/cancel', async c => { + try { return success(c, await cancelGenerationTask(Number(c.req.param('id')))) } + catch (error: any) { return badRequest(c, safeTaskError(error)) } +}) + app.post('/:id/cancel-waiting', async c => { try { await cancelWaitingVideo(Number(c.req.param('id'))); return success(c) } catch (error: any) { return badRequest(c, error.message) } @@ -253,7 +258,7 @@ app.get('/:id', async (c) => { const id = Number(c.req.param('id')) const [row] = await db.select().from(schema.sysTask) .where(eq(schema.sysTask.id, id)) - return success(c, row ? { ...row, routing: taskRouting(row) } : null) + return success(c, row ? { ...row, routing: taskRouting(row), canCancel: canCancelGenerationTask(row) } : null) }) // GET /tasks — 按 type / storyboard_id / drama_id 过滤 @@ -268,7 +273,7 @@ app.get('/', async (c) => { if (storyboardId) rows = rows.filter(r => r.storyboardId === Number(storyboardId)) if (dramaId) rows = rows.filter(r => r.dramaId === Number(dramaId)) - return success(c, rows.map(row => ({ ...row, routing: taskRouting(row) }))) + return success(c, rows.map(row => ({ ...row, routing: taskRouting(row), canCancel: canCancelGenerationTask(row) }))) }) // DELETE /tasks/:id diff --git a/huobao-source/backend/src/services/adapters/minimax-video.ts b/huobao-source/backend/src/services/adapters/minimax-video.ts index b31148d..70ede65 100644 --- a/huobao-source/backend/src/services/adapters/minimax-video.ts +++ b/huobao-source/backend/src/services/adapters/minimax-video.ts @@ -150,7 +150,7 @@ export class MiniMaxVideoAdapter implements VideoProviderAdapter { const err = task.error const msg = typeof err === 'string' ? err : (err?.message || JSON.stringify(err) || 'Video generation failed') const code = err && typeof err === 'object' && err.code ? `[${err.code}] ` : '' - return { status: 'failed', error: `${code}${msg}` } + return { status: 'failed', error: `${code}${msg}`, cancelled: status === 'cancelled' } } const workerStage = ['queued', 'running', 'checking', 'missing'].includes(task.worker_state) ? task.worker_state : undefined return { status: 'processing', stage: task.delivery?.status === 'transferring' ? 'transferring' : workerStage, routing: parseWorkerRouting(task.routing), progress: parseTaskProgress(task.progress) } diff --git a/huobao-source/backend/src/services/adapters/openai-image.ts b/huobao-source/backend/src/services/adapters/openai-image.ts index e43bf9e..14d1fda 100644 --- a/huobao-source/backend/src/services/adapters/openai-image.ts +++ b/huobao-source/backend/src/services/adapters/openai-image.ts @@ -175,7 +175,7 @@ export class OpenAIImageAdapter implements ImageProviderAdapter { } } if (result.status === 'failed') { - return { status: 'failed', error: result.error?.message || 'Generation failed' } + return { status: 'failed', error: result.error?.message || 'Generation failed', cancelled: result.cancelled === true } } return { status: result.status || 'processing', stage: ['queued', 'running', 'checking', 'missing'].includes(result.worker_state) ? result.worker_state : undefined } } diff --git a/huobao-source/backend/src/services/adapters/types.ts b/huobao-source/backend/src/services/adapters/types.ts index c51ed0b..d35ac82 100644 --- a/huobao-source/backend/src/services/adapters/types.ts +++ b/huobao-source/backend/src/services/adapters/types.ts @@ -123,6 +123,7 @@ export interface ImageGenResponse { } export interface ImagePollResponse { + cancelled?: boolean stage?: 'queued' | 'running' | 'checking' | 'missing' status: 'pending' | 'processing' | 'completed' | 'failed' imageUrl?: string @@ -138,6 +139,7 @@ export interface VideoGenResponse { } export interface VideoPollResponse { + cancelled?: boolean progress?: import('../task-progress').TaskProgress | null routing?: import('../worker-routing').WorkerRouting | null status: 'pending' | 'processing' | 'completed' | 'failed' diff --git a/huobao-source/backend/src/services/generation.ts b/huobao-source/backend/src/services/generation.ts index 6adde76..bd8435e 100644 --- a/huobao-source/backend/src/services/generation.ts +++ b/huobao-source/backend/src/services/generation.ts @@ -11,6 +11,7 @@ import { prepareReferenceAudio, resolveReferenceAudioUrls } from './reference-au import { getActiveConfig, getConfigById } from './ai.js' import { parseWorkerRouting, type WorkerRouting } from './worker-routing.js' import { isLocalQueuedTask, taskPollingProfile } from './local-task-tracking.js' +import { joinProviderUrl } from './adapters/url' import { now } from '../utils/response.js' import { downloadFile, fetchImageAsCompressedDataUrl, generateImageThumb, readImageAsCompressedDataUrl, readImageAsDataUrl, saveBase64Image } from '../utils/storage.js' import { extractVideoPoster } from '../utils/video-poster.js' @@ -223,7 +224,7 @@ export async function generateVideo(params: GenerateVideoParams): Promise { + if (!Number.isSafeInteger(id) || id <= 0) throw new Error('任务编号无效') + const record = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)).get() + if (!record) throw new Error('任务不存在') + if (record.errorCode === 'user_cancelled') return { outcome: 'cancelled' } + if (record.status !== 'processing') throw new Error('任务已结束,请刷新列表') + if (cancellingTasks.has(id)) return { outcome: 'pending' } + if (!canCancelGenerationTask(record)) throw new Error('当前任务尚未返回编号、正在保存或不支持取消,请稍后刷新') + if (!record.taskId) { await cancelWaitingVideo(id); return { outcome: 'cancelled' } } + cancellingTasks.add(id) try { + const config = await recoveryConfig(record) + const current = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)).get() + if (!current || !canCancelGenerationTask(current)) throw new Error('任务状态已变化,请刷新后操作') + await db.update(schema.sysTask).set({ stage: 'cancelling', params: JSON.stringify({ ...parseTaskParams(current.params), + cancellation: { requestedAt: now(), reason } }), updatedAt: now() }) + .where(and(eq(schema.sysTask.id, id), eq(schema.sysTask.status, 'processing'))) + const headers = { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' } const request = async (cancel: boolean) => { - const path = cancel ? `/v2/video_generation/${encodeURIComponent(record.taskId!)}/cancel` : `/v2/query/video_generation/${encodeURIComponent(record.taskId!)}` - const response = await fetch(base + path, { method: cancel ? 'POST' : 'GET', headers, + const job = encodeURIComponent(record.taskId!) + const path = record.type === 'image' ? `/images/task/${job}${cancel ? '/cancel' : ''}` + : cancel ? `/video_generation/${job}/cancel` : `/query/video_generation/${job}` + const response = await fetch(joinProviderUrl(config.baseUrl, record.type === 'image' ? '/v1' : '/v2', path), { method: cancel ? 'POST' : 'GET', headers, ...(cancel ? { body: '{}' } : {}), signal: AbortSignal.timeout(35_000) }) - if (!response.ok) throw new Error('原任务取消未确认,请稍后重试;未提交新任务') + if (!response.ok) throw new Error('任务取消未确认,请稍后重试') const result = await response.json() as any - if (result.task?.id !== record.taskId) throw new Error('取消回执任务编号不匹配;未提交新任务') - return result.task.status as string + const receipt = record.type === 'image' && !cancel ? { id: result.task_id, + status: result.cancelled ? 'cancelled' : result.status === 'completed' ? 'succeeded' : result.status } : result.task + if (receipt?.id !== record.taskId) throw new Error('取消回执任务编号不匹配') + return receipt.status as string } - let status = await request(true) + let status: string const deadline = Date.now() + 45_000 - while (!['cancelled', 'failed', 'succeeded'].includes(status) && Date.now() < deadline) { - await new Promise(resolve => setTimeout(resolve, 1000)) - status = await request(false) + try { + status = await request(true) + while (!['cancelled', 'failed', 'succeeded'].includes(status) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 1000)) + status = await request(false) + } + } catch { + // The request may have reached the worker. Keep tracking the same ID; + // only an exact terminal receipt can declare it cancelled. + return { outcome: 'pending' } } - if (status === 'succeeded') throw new Error('原视频已经生成,正在保存;完成后可以重新生成') - if (!['cancelled', 'failed'].includes(status)) throw new Error('原任务仍在停止中,请稍后再点击;未提交新任务') - await db.update(schema.sysTask).set({ status: 'failed', stage: 'failed', errorCode: 'user_cancelled', - errorMsg: '已取消原任务并请求重新生成,历史记录保留', updatedAt: now() }) - .where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing'))) - } finally { cancellingTasks.delete(record.id) } + if (status === 'cancelled') { await markTaskCancelled(id, reason); return { outcome: 'cancelled' } } + if (status === 'failed') { await failTask(id, '生成节点确认任务已失败', 'generation_failed'); return { outcome: 'failed' } } + if (status === 'succeeded') { + await db.update(schema.sysTask).set({ stage: 'polling', updatedAt: now() }).where(and(eq(schema.sysTask.id, id), eq(schema.sysTask.status, 'processing'))) + return { outcome: 'completed' } + } + return { outcome: 'pending' } + } finally { cancellingTasks.delete(id) } } async function createTask( @@ -553,6 +592,7 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string) if (cancellingTasks.has(record.id)) continue const [afterPoll] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id)) if (!afterPoll || afterPoll.status !== 'processing') return + if (cancellingTasks.has(record.id)) continue // 图片/视频 PollResponse 结构不同,这里统一按 any 取值后按 type 分支 const pollResp: any = adapter.parsePollResponse(result) @@ -563,7 +603,7 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string) await failTask(record.id, '显卡节点已找不到此任务,请核对节点重启记录后重新生成', 'upstream_missing') return } - const stage = pollResp.stage === 'missing' ? 'checking' : pollResp.stage || 'polling' + const stage = parseTaskParams(afterPoll.params).cancellation ? 'cancelling' : pollResp.stage === 'missing' ? 'checking' : pollResp.stage || 'polling' await db.update(schema.sysTask).set({ stage, updatedAt: now() }) .where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing'))) } @@ -582,7 +622,7 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string) if (type === 'image') { if (pollResp.imageUrl) { logTaskSuccess(label, 'poll-complete', { id: record.id, taskId, imageUrl: pollResp.imageUrl }) - await handleImageComplete(record, pollResp.imageUrl) + if (await handleImageComplete(record, pollResp.imageUrl) === false) continue return } if (adapter.provider === 'gemini') { @@ -596,11 +636,12 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string) } } else if (pollResp.videoUrl) { logTaskSuccess(label, 'poll-complete', { id: record.id, taskId, videoUrl: pollResp.videoUrl }) - await handleVideoComplete(record, pollResp.videoUrl, pollResp.duration) + if (await handleVideoComplete(record, pollResp.videoUrl, pollResp.duration) === false) continue return } } if (pollResp.status === 'failed') { + if (pollResp.cancelled) { await markTaskCancelled(record.id, parseTaskParams(afterPoll.params).cancellation?.reason); return } // 上游明确失败(如内容审核拦截)属终态:立即落库,不重试不等待超时 const deliveryFailed = /video_transfer_failed/.test(pollResp.error || '') if (deliveryFailed) await db.update(schema.sysTask).set({ stage: 'transferring' }).where(eq(schema.sysTask.id, record.id)) @@ -630,6 +671,11 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string) } async function handleImageComplete(record: SysTaskRecord, imageUrl: string) { + const current = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id)).get() + if (!current || current.errorCode === 'user_cancelled') return + if (cancellingTasks.has(record.id)) return false + await db.update(schema.sysTask).set({ resultUrl: imageUrl, stage: 'downloading', updatedAt: now() }) + .where(eq(schema.sysTask.id, record.id)) const localPath = await downloadFile(imageUrl, 'images') // 列表页缩略图(前端按命名约定推导地址,失败不影响主流程) await generateImageThumb(localPath) @@ -684,7 +730,8 @@ async function writeBackImageAssets(record: SysTaskRecord, localPath: string) { async function handleVideoComplete(record: SysTaskRecord, videoUrl: string, duration: number | null | undefined) { const [current] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id)) - if (!current || current.errorCode === 'user_cancelled' || cancellingTasks.has(record.id)) return + if (!current || current.errorCode === 'user_cancelled') return + if (cancellingTasks.has(record.id)) return false // 先落库地址与实际时长;后续下载失败仍可恢复,且不重新提交生成。 const params: Record = { ...parseTaskParams(current.params), resultDuration: duration } await db.update(schema.sysTask).set({ resultUrl: videoUrl, params: JSON.stringify(params), diff --git a/huobao-source/frontend/app/components/TaskProgress.vue b/huobao-source/frontend/app/components/TaskProgress.vue index 37e80f9..16afa22 100644 --- a/huobao-source/frontend/app/components/TaskProgress.vue +++ b/huobao-source/frontend/app/components/TaskProgress.vue @@ -12,6 +12,7 @@

同显卡、同规格通常耗时约 {{ timeRange(progress.historyMinSeconds, progress.historyMaxSeconds) }}参考 {{ progress.historySamples }} 次成功执行,不含排队与下载;属于估算。

整段视频预计耗时:暂无足够的同规格记录。

视频生成已结束,正在回传并保存到本地。

+

已请求停止此任务,正在等待显卡确认。

{{ progress ? '进度更新暂时延迟,仍在跟踪原任务。' : '正在读取显卡进度…' }}

@@ -27,12 +28,12 @@ const progress = computed(() => { try { return JSON.parse(props.task?.params || '{}').progress || null } catch { return null } }) const finishing = computed(() => ['transferring', 'downloading', 'saving'].includes(props.task?.stage)) -const fresh = computed(() => !finishing.value && progress.value && clock.value - progress.value.observedAt < 90000) +const fresh = computed(() => !finishing.value && props.task?.stage !== 'cancelling' && progress.value && clock.value - progress.value.observedAt < 90000) const samplingFresh = computed(() => fresh.value && clock.value - progress.value.observedAt < 30000) -const waiting = computed(() => ['waiting_predecessor', 'blocked_predecessor'].includes(props.task?.stage)) +const waiting = computed(() => ['waiting_predecessor', 'blocked_predecessor', 'cancelling'].includes(props.task?.stage)) const phases: Record = { queued:'等待显卡队列', running:'显卡正在处理', sampling:'视频采样中', decoding:'视频 / 音频解码中', encoding:'编码与保存中', loading:'模型 / 素材加载中', conditioning:'参考素材与提示词处理', postprocessing:'声音 / 字幕后处理', - waiting_predecessor:'等待前镜头完成', blocked_predecessor:'等待处理前镜头', transferring:'视频回传中', downloading:'视频下载中', saving:'保存视频中', submitting:'提交任务中' } + waiting_predecessor:'等待前镜头完成', blocked_predecessor:'等待处理前镜头', cancelling:'正在确认取消', transferring:'视频回传中', downloading:'视频下载中', saving:'保存视频中', submitting:'提交任务中' } const phaseLabel = computed(() => { const stage = props.task?.stage return phases[waiting.value || ['transferring','downloading','saving','submitting'].includes(stage) ? stage : fresh.value ? progress.value.phase : stage] || '生成任务处理中' diff --git a/huobao-source/frontend/app/composables/useApi.ts b/huobao-source/frontend/app/composables/useApi.ts index 3a3af45..7f407de 100644 --- a/huobao-source/frontend/app/composables/useApi.ts +++ b/huobao-source/frontend/app/composables/useApi.ts @@ -105,6 +105,7 @@ export const propAPI = { // 统一生成任务(图片/视频):POST 带 type 字段,列表按 type 过滤 export const taskAPI = { + cancel: (id: number) => api.post(`/tasks/${id}/cancel`), generate: (d: any) => api.post('/tasks', d), recover: (id: number) => api.post(`/tasks/${id}/recover`), diagnostics: (id: number) => api.get(`/tasks/${id}/diagnostics`), diff --git a/huobao-source/frontend/app/views/drama/episode.vue b/huobao-source/frontend/app/views/drama/episode.vue index d8dc8e5..cb3f153 100644 --- a/huobao-source/frontend/app/views/drama/episode.vue +++ b/huobao-source/frontend/app/views/drama/episode.vue @@ -1043,6 +1043,7 @@ {{ t('episode.vid.metricPending', { n: genTaskActiveCount }) }} {{ t('episode.vid.metricDone', { n: genTaskDoneCount }) }} {{ t('episode.vid.metricFailed', { n: genTaskFailedCount }) }} + {{ genTaskCancelledCount }} 已取消
@@ -1094,8 +1095,14 @@ #{{ row.id }}
-
- {{ mapError(row.errorMsg) }} +
+ + 正在等待显卡确认 +
+
+ {{ row.status === 'cancelled' ? row.errorMsg : mapError(row.errorMsg) }}
@@ -2016,6 +2023,7 @@ function videoModerationHint(msg) { function videoTaskState(sb) { if (isPendingVideo(sb?.id)) return 'pending' if (hasVid(sb)) return 'done' + if (latestVideoTask(sb?.id)?.error_code === 'user_cancelled') return 'cancelled' if (videoFailMessage(sb?.id)) return 'failed' return 'ready' } @@ -2073,6 +2081,7 @@ async function recoverTask(taskId, storyboardId) { function videoTaskStatusLabel(sb) { const state = videoTaskState(sb) if (state === 'done') return t('episode.status.done') + if (state === 'cancelled') return '已取消' if (state === 'pending') { const stage = latestVideoTask(sb.id)?.stage return stage === 'downloading' ? t('episode.recovery.downloading') @@ -2281,6 +2290,7 @@ function videoTaskRoutingLabel(task, compact = false) { } function canReplaceVideo(sb) { const task = latestVideoTask(sb?.id) + if (task?.stage === 'cancelling') return false if (task?.status === 'processing' && ['waiting_predecessor', 'blocked_predecessor'].includes(task.stage)) return true return task?.status === 'processing' && !!task.task_id && ['minimax', 'localvideo'].includes(task.provider) && !task.result_url && !['submitting', 'transferring', 'downloading', 'saving'].includes(task.stage) @@ -2407,9 +2417,27 @@ const genTaskDoneCount = computed(() => genMerges.value.filter(m => m.status === 'completed').length ) const genTaskFailedCount = computed(() => - genTasks.value.filter(t => t.status === 'failed').length + + genTasks.value.filter(t => t.status === 'failed' && t.error_code !== 'user_cancelled').length + genMerges.value.filter(m => m.status === 'failed').length ) +const genTaskCancelledCount = computed(() => genTasks.value.filter(t => t.error_code === 'user_cancelled').length) +const cancellingTaskIds = ref([]) +async function cancelTask(row) { + if (!row.canCancel || cancellingTaskIds.value.includes(row.id)) return + cancellingTaskIds.value.push(row.id) + try { + const result = await taskAPI.cancel(row.id) + if (result.outcome === 'cancelled') toast.success('任务已取消,历史记录和已有素材保留') + else if (result.outcome === 'completed') toast.info('任务已经生成,正在保存结果') + else if (result.outcome === 'failed') toast.info('生成节点确认任务已结束,请查看任务记录') + else toast.info('已请求取消,正在等待显卡确认;可稍后重试取消') + } catch (error) { toastError(error) } + finally { + cancellingTaskIds.value = cancellingTaskIds.value.filter(id => id !== row.id) + await refresh() + await loadGenTasks() + } +} function genTaskTargetLabel(task) { if (task.storyboard_id) { @@ -2441,7 +2469,8 @@ const genTaskRows = computed(() => { targetLabel: genTaskTargetLabel(t), provider: t.provider || '', model: t.model || '', - status: t.status || 'processing', + status: t.error_code === 'user_cancelled' ? 'cancelled' : t.status || 'processing', + canCancel: t.can_cancel === true, errorMsg: t.error_msg || '', stage: t.stage || '', errorCode: t.error_code || '', @@ -2474,11 +2503,13 @@ function genTaskKindLabel(kind) { } function trackedStageLabel(stage) { + if (stage === 'cancelling') return '正在确认取消' if (stage === 'waiting_predecessor') return '等待前镜头' if (stage === 'blocked_predecessor') return '前镜头待处理' return ['queued', 'running', 'checking', 'reconnecting'].includes(stage) ? t('taskTracking.' + stage) : '' } function genTaskStatusLabel(status, stage, errorCode) { + if (status === 'cancelled' || errorCode === 'user_cancelled') return '已取消' if (errorCode === 'upstream_missing') return t('taskTracking.missing') if (status === 'completed') return t('episode.status.done') if (status === 'failed') return t('episode.status.failed') @@ -2487,6 +2518,7 @@ function genTaskStatusLabel(status, stage, errorCode) { // 映射到现有 video-task-status 的样式类:is-done / is-pending / is-failed function genTaskStateClass(status) { + if (status === 'cancelled') return 'cancelled' if (status === 'completed') return 'done' if (status === 'failed') return 'failed' return 'pending' @@ -3538,6 +3570,11 @@ async function pollVideoGeneration(generationId, storyboardId) { return } if (res?.status === 'failed') { + if ((res.errorCode || res.error_code) === 'user_cancelled') { + pendingVideoIds.value = pendingVideoIds.value.filter(item => item !== storyboardId) + delete failedVideoMessages.value[storyboardId] + return + } pendingVideoIds.value = pendingVideoIds.value.filter(item => item !== storyboardId) const errMsg = res?.error_msg || res?.errorMsg || t('episode.vid.genFailed') failedVideoMessages.value = { @@ -3605,6 +3642,10 @@ onMounted(() => setTimeout(() => autoTour('episode', EPISODE_TOUR, t), 900))