feat: add generation job cancel and retry queue
This commit is contained in:
@@ -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<string | null>(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<GenerationJobDetail>(`/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<GenerationJobActionResponse>(
|
||||
`/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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -253,6 +299,22 @@ async function generateStory() {
|
||||
:title="generationTitle"
|
||||
:steps="generationSteps"
|
||||
/>
|
||||
<div
|
||||
v-if="loading && activeGenerationJobId"
|
||||
class="fixed bottom-10 z-[110] flex flex-col items-center gap-3"
|
||||
>
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
:loading="canceling"
|
||||
:disabled="cancelRequested"
|
||||
@click="cancelGenerationJob"
|
||||
>
|
||||
{{ cancelRequested ? '正在取消任务...' : '取消任务' }}
|
||||
</BaseButton>
|
||||
<p class="text-sm text-white/70">
|
||||
{{ cancelRequested ? '已提交取消请求,会在安全检查点停止任务。' : '如果是误触发起,可以现在取消后台任务。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div v-else class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8">
|
||||
|
||||
@@ -14,6 +14,8 @@ interface GenerationJobSummary {
|
||||
progress_percent: number
|
||||
progress_label: string
|
||||
is_terminal: boolean
|
||||
can_cancel: boolean
|
||||
can_retry: boolean
|
||||
result_snapshot: Record<string, unknown>
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
@@ -63,6 +65,7 @@ const jobs = ref<GenerationJobSummary[]>([])
|
||||
const activeJob = ref<GenerationJobDetail | null>(null)
|
||||
const providerStats = ref<GenerationProviderStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const error = ref('')
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -94,6 +97,7 @@ const statusClassMap: Record<string, string> = {
|
||||
succeeded: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
degraded_completed: 'border-orange-200 bg-orange-50 text-orange-700',
|
||||
canceled: 'border-slate-200 bg-slate-100 text-slate-700',
|
||||
failed: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
}
|
||||
|
||||
@@ -108,6 +112,7 @@ function statusLabel(status?: string) {
|
||||
succeeded: '成功',
|
||||
completed: '已完成',
|
||||
degraded_completed: '降级完成',
|
||||
canceled: '已取消',
|
||||
failed: '失败',
|
||||
}
|
||||
return labels[status ?? ''] ?? '未知'
|
||||
@@ -117,6 +122,8 @@ function eventLabel(eventType: string) {
|
||||
const labels: Record<string, string> = {
|
||||
request_accepted: '请求接收',
|
||||
worker_started: '后台任务开始',
|
||||
retry_queued: '重新排队',
|
||||
cancel_requested: '已请求取消',
|
||||
context_prepared: '上下文准备',
|
||||
narrative_generated: '正文生成',
|
||||
story_saved: '故事保存',
|
||||
@@ -137,6 +144,7 @@ function eventLabel(eventType: string) {
|
||||
asset_retry_started: '资源重试开始',
|
||||
asset_retry_completed: '资源重试完成',
|
||||
asset_retry_failed: '资源重试失败',
|
||||
generation_canceled: '任务已取消',
|
||||
generation_completed: '生成完成',
|
||||
generation_failed: '生成失败',
|
||||
}
|
||||
@@ -213,6 +221,36 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelActiveJob() {
|
||||
if (!activeJob.value || actionLoading.value) return
|
||||
|
||||
actionLoading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(`/api/generations/jobs/${activeJob.value.id}/cancel`)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '取消任务失败'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryActiveJob() {
|
||||
if (!activeJob.value || actionLoading.value) return
|
||||
|
||||
actionLoading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(`/api/generations/jobs/${activeJob.value.id}/retry`)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '重新排队失败'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
@@ -334,9 +372,29 @@ defineExpose({ refresh })
|
||||
当前步骤:{{ eventLabel(activeJob.current_step) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="statusClass(activeJob.status)">
|
||||
{{ statusLabel(activeJob.status) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
v-if="activeJob.can_cancel"
|
||||
type="button"
|
||||
class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="actionLoading"
|
||||
@click="cancelActiveJob"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '取消任务' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="activeJob.can_retry"
|
||||
type="button"
|
||||
class="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-medium text-sky-700 transition hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="actionLoading"
|
||||
@click="retryActiveJob"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '重新排队' }}
|
||||
</button>
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="statusClass(activeJob.status)">
|
||||
{{ statusLabel(activeJob.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user