feat: add generation trace and partial-ready workflow status
This commit is contained in:
347
frontend/src/components/GenerationTrace.vue
Normal file
347
frontend/src/components/GenerationTrace.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline'
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
GenerationJobDetail,
|
||||
GenerationJobEvent,
|
||||
GenerationJobSummary,
|
||||
GenerationProviderStats,
|
||||
} from '../types/generation'
|
||||
import LoadingSpinner from './ui/LoadingSpinner.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
storyId: number | null
|
||||
tone?: 'light' | 'dark'
|
||||
title?: string
|
||||
description?: string
|
||||
}>(),
|
||||
{
|
||||
tone: 'light',
|
||||
title: '生成轨迹',
|
||||
description: '每次生成和资源重试都会留下事件流,便于确认当前结果来自哪次任务、Provider 是否成功、失败是否可恢复。',
|
||||
},
|
||||
)
|
||||
|
||||
const jobHistory = ref<GenerationJobSummary[]>([])
|
||||
const activeJob = ref<GenerationJobDetail | null>(null)
|
||||
const providerStats = ref<GenerationProviderStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const isDark = computed(() => props.tone === 'dark')
|
||||
const latestJob = computed(() => jobHistory.value[0] ?? null)
|
||||
const activeJobEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
|
||||
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
|
||||
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
|
||||
const providerSuccessRate = computed(() => {
|
||||
if (!providerStats.value?.total_calls) return null
|
||||
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
|
||||
})
|
||||
|
||||
const containerClass = computed(() => (
|
||||
isDark.value
|
||||
? 'border-white/10 bg-white/10 text-white shadow-2xl backdrop-blur'
|
||||
: 'border-gray-100 bg-white/85 text-gray-900'
|
||||
))
|
||||
const mutedTextClass = computed(() => (isDark.value ? 'text-white/65' : 'text-gray-500'))
|
||||
const panelClass = computed(() => (
|
||||
isDark.value
|
||||
? 'border-white/10 bg-black/25'
|
||||
: 'border-gray-100 bg-gray-50/80'
|
||||
))
|
||||
const itemClass = computed(() => (
|
||||
isDark.value
|
||||
? 'border-white/10 bg-white/10 hover:bg-white/15'
|
||||
: 'border-gray-100 bg-white hover:border-gray-300 hover:bg-gray-50'
|
||||
))
|
||||
const activeItemClass = computed(() => (
|
||||
isDark.value
|
||||
? 'border-white/60 bg-white/20'
|
||||
: 'border-gray-900 bg-gray-50'
|
||||
))
|
||||
const eventCardClass = computed(() => (
|
||||
isDark.value
|
||||
? 'border-white/10 bg-white/10'
|
||||
: 'border-white bg-white'
|
||||
))
|
||||
|
||||
const jobStatusClassMap: Record<string, string> = {
|
||||
running: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
queued: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
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',
|
||||
failed: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
}
|
||||
|
||||
function getJobStatusClass(status?: string) {
|
||||
return jobStatusClassMap[status ?? ''] ?? 'border-gray-200 bg-gray-50 text-gray-600'
|
||||
}
|
||||
|
||||
function getJobStatusLabel(status?: string) {
|
||||
const labels: Record<string, string> = {
|
||||
running: '进行中',
|
||||
queued: '已排队',
|
||||
succeeded: '成功',
|
||||
completed: '已完成',
|
||||
degraded_completed: '降级完成',
|
||||
failed: '失败',
|
||||
}
|
||||
return labels[status ?? ''] ?? '未知'
|
||||
}
|
||||
|
||||
function getEventLabel(eventType: string) {
|
||||
const labels: Record<string, string> = {
|
||||
request_accepted: '请求接收',
|
||||
context_prepared: '上下文准备',
|
||||
narrative_generated: '正文生成',
|
||||
story_saved: '故事保存',
|
||||
cover_image_started: '封面开始',
|
||||
cover_image_succeeded: '封面就绪',
|
||||
cover_image_failed: '封面失败',
|
||||
storybook_images_started: '绘本插图开始',
|
||||
storybook_images_completed: '绘本插图完成',
|
||||
storybook_cover_image_succeeded: '绘本封面就绪',
|
||||
storybook_cover_image_failed: '绘本封面失败',
|
||||
storybook_page_image_succeeded: '分页插图就绪',
|
||||
storybook_page_image_failed: '分页插图失败',
|
||||
audio_cache_hit: '音频缓存命中',
|
||||
audio_started: '音频开始',
|
||||
audio_succeeded: '音频就绪',
|
||||
audio_failed: '音频失败',
|
||||
provider_call_started: 'Provider 调用',
|
||||
provider_call_succeeded: 'Provider 成功',
|
||||
provider_call_failed: 'Provider 失败',
|
||||
asset_retry_started: '资源重试开始',
|
||||
asset_retry_completed: '资源重试完成',
|
||||
asset_retry_failed: '资源重试失败',
|
||||
generation_completed: '生成完成',
|
||||
generation_failed: '生成失败',
|
||||
asset_generation_completed: '资源生成完成',
|
||||
asset_generation_failed: '资源生成失败',
|
||||
}
|
||||
return labels[eventType] ?? eventType
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function getProviderEventText(event: GenerationJobEvent) {
|
||||
const meta = event.event_metadata
|
||||
const adapter = meta.adapter ? String(meta.adapter) : ''
|
||||
const latency = typeof meta.latency_ms === 'number' ? `${meta.latency_ms}ms` : ''
|
||||
const cost = typeof meta.estimated_cost_usd === 'number'
|
||||
? `$${meta.estimated_cost_usd.toFixed(4)}`
|
||||
: ''
|
||||
return [adapter, latency, cost].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function formatLatency(value?: number | null) {
|
||||
return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
|
||||
}
|
||||
|
||||
function formatCost(value?: number | null) {
|
||||
return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
|
||||
}
|
||||
|
||||
async function selectGenerationJob(jobId: string) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
activeJob.value = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (props.storyId === null) {
|
||||
jobHistory.value = []
|
||||
activeJob.value = null
|
||||
providerStats.value = null
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const [jobs, stats] = await Promise.all([
|
||||
api.get<GenerationJobSummary[]>(`/api/generations/${props.storyId}/jobs`),
|
||||
api.get<GenerationProviderStats>(`/api/generations/${props.storyId}/provider-stats`),
|
||||
])
|
||||
jobHistory.value = jobs
|
||||
providerStats.value = stats
|
||||
const nextJobId = jobHistory.value[0]?.id
|
||||
if (nextJobId) {
|
||||
await selectGenerationJob(nextJobId)
|
||||
} else {
|
||||
activeJob.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
jobHistory.value = []
|
||||
activeJob.value = null
|
||||
providerStats.value = null
|
||||
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.storyId,
|
||||
() => {
|
||||
void refresh()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-lg border p-5" :class="containerClass">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<BoltIcon class="h-5 w-5 text-amber-500" />
|
||||
{{ props.title }}
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6" :class="mutedTextClass">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="latestJob"
|
||||
class="w-fit rounded-full border px-3 py-1.5 text-sm font-medium"
|
||||
:class="getJobStatusClass(latestJob.status)"
|
||||
>
|
||||
最近任务:{{ getJobStatusLabel(latestJob.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-sm text-rose-600">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-5">
|
||||
<div
|
||||
v-if="providerStats?.total_calls"
|
||||
class="grid gap-3 md:grid-cols-4"
|
||||
>
|
||||
<div class="rounded-lg border p-3" :class="panelClass">
|
||||
<div class="text-xs" :class="mutedTextClass">Provider 成功率</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ providerSuccessRate }}%</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-3" :class="panelClass">
|
||||
<div class="text-xs" :class="mutedTextClass">平均耗时</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ formatLatency(providerStats.avg_latency_ms) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-3" :class="panelClass">
|
||||
<div class="text-xs" :class="mutedTextClass">预估成本</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ formatCost(providerStats.estimated_cost_usd) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-3" :class="panelClass">
|
||||
<div class="text-xs" :class="mutedTextClass">调用次数</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ providerStats.total_calls }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!jobHistory.length" class="rounded-lg border border-dashed border-gray-200 p-4 text-sm" :class="mutedTextClass">
|
||||
暂无生成轨迹。旧数据会在下一次资源补全后开始记录。
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 lg:block lg:space-y-2 lg:overflow-visible">
|
||||
<button
|
||||
v-for="job in jobHistory"
|
||||
:key="job.id"
|
||||
type="button"
|
||||
class="min-w-[190px] rounded-lg border px-3 py-3 text-left transition lg:w-full"
|
||||
:class="[itemClass, activeJob?.id === job.id ? activeItemClass : '']"
|
||||
@click="selectGenerationJob(job.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.output_mode === 'asset_retry' ? '资源重试' : '内容生成' }}
|
||||
</span>
|
||||
<span class="rounded-full border px-2 py-0.5 text-xs" :class="getJobStatusClass(job.status)">
|
||||
{{ getJobStatusLabel(job.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-1 text-xs" :class="mutedTextClass">
|
||||
<ClockIcon class="h-4 w-4" />
|
||||
{{ formatDateTime(job.created_at) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4" :class="panelClass">
|
||||
<LoadingSpinner v-if="loading" size="sm" text="正在读取生成轨迹..." />
|
||||
|
||||
<div v-else-if="activeJob" class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">
|
||||
{{ activeJob.output_mode === 'asset_retry' ? '资源重试事件' : '生成事件' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs" :class="mutedTextClass">
|
||||
当前步骤:{{ getEventLabel(activeJob.current_step) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="getJobStatusClass(activeJob.status)">
|
||||
{{ getJobStatusLabel(activeJob.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-xs" :class="mutedTextClass">
|
||||
<span>{{ activeProgressLabel }}</span>
|
||||
<span>{{ activeProgress }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200/80">
|
||||
<div
|
||||
class="h-full rounded-full bg-emerald-500 transition-all duration-300"
|
||||
:style="{ width: `${activeProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="space-y-3">
|
||||
<li
|
||||
v-for="event in activeJobEvents"
|
||||
:key="event.id"
|
||||
class="grid grid-cols-[88px_minmax(0,1fr)] gap-3"
|
||||
>
|
||||
<div class="pt-1 text-xs" :class="mutedTextClass">
|
||||
{{ formatDateTime(event.created_at) }}
|
||||
</div>
|
||||
<div class="rounded-lg border px-3 py-2" :class="eventCardClass">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold">{{ getEventLabel(event.event_type) }}</span>
|
||||
<span class="rounded-full border px-2 py-0.5 text-xs" :class="getJobStatusClass(event.status)">
|
||||
{{ getJobStatusLabel(event.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="event.event_type.startsWith('provider_call')" class="mt-1 text-xs" :class="mutedTextClass">
|
||||
{{ getProviderEventText(event) }}
|
||||
</p>
|
||||
<p v-else-if="event.message" class="mt-1 text-xs" :class="mutedTextClass">
|
||||
{{ event.message }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@ export interface Storybook {
|
||||
cover_prompt: string
|
||||
cover_url?: string
|
||||
generation_status?: string
|
||||
text_status?: string
|
||||
image_status?: string
|
||||
audio_status?: string
|
||||
last_error?: string | null
|
||||
|
||||
51
frontend/src/types/generation.ts
Normal file
51
frontend/src/types/generation.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface GenerationJobSummary {
|
||||
id: string
|
||||
story_id: number | null
|
||||
output_mode: string
|
||||
input_type: string
|
||||
status: string
|
||||
current_step: string
|
||||
progress_percent: number
|
||||
progress_label: string
|
||||
is_terminal: boolean
|
||||
result_snapshot: Record<string, unknown>
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GenerationJobEvent {
|
||||
id: number
|
||||
job_id: string
|
||||
story_id: number | null
|
||||
event_type: string
|
||||
status: string
|
||||
message: string | null
|
||||
event_metadata: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GenerationJobDetail extends GenerationJobSummary {
|
||||
request_payload: Record<string, unknown>
|
||||
events: GenerationJobEvent[]
|
||||
}
|
||||
|
||||
export interface GenerationProviderStat {
|
||||
capability: string
|
||||
adapter: string
|
||||
call_count: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
avg_latency_ms: number | null
|
||||
estimated_cost_usd: number
|
||||
}
|
||||
|
||||
export interface GenerationProviderStats {
|
||||
story_id: number
|
||||
total_calls: number
|
||||
successful_calls: number
|
||||
failed_calls: number
|
||||
avg_latency_ms: number | null
|
||||
estimated_cost_usd: number
|
||||
by_provider: GenerationProviderStat[]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type StoryGenerationStatus =
|
||||
| 'narrative_ready'
|
||||
| 'partial_ready'
|
||||
| 'assets_generating'
|
||||
| 'completed'
|
||||
| 'degraded_completed'
|
||||
@@ -23,6 +24,11 @@ const generationStatusMetaMap: Record<StoryGenerationStatus, StatusMeta> = {
|
||||
description: '故事内容已经生成,可以继续补充封面或音频。',
|
||||
badgeClass: 'bg-sky-50 text-sky-700 border border-sky-100',
|
||||
},
|
||||
partial_ready: {
|
||||
label: '可先阅读',
|
||||
description: '主内容已经可用,仍有封面、插图或音频可以继续补全。',
|
||||
badgeClass: 'bg-cyan-50 text-cyan-700 border border-cyan-100',
|
||||
},
|
||||
assets_generating: {
|
||||
label: '资源生成中',
|
||||
description: '封面或音频正在生成中,请稍候查看结果。',
|
||||
@@ -77,3 +83,13 @@ export function getAssetStatusMeta(status?: string): StatusMeta {
|
||||
return assetStatusMetaMap[(status ?? 'not_requested') as StoryAssetStatus]
|
||||
?? assetStatusMetaMap.not_requested
|
||||
}
|
||||
|
||||
export function isReadableGenerationStatus(status?: string) {
|
||||
return ['narrative_ready', 'partial_ready', 'completed', 'degraded_completed']
|
||||
.includes(status ?? '')
|
||||
}
|
||||
|
||||
export function needsGenerationAttention(status?: string) {
|
||||
return ['partial_ready', 'assets_generating', 'degraded_completed', 'failed']
|
||||
.includes(status ?? '')
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
|
||||
import {
|
||||
getAssetStatusMeta,
|
||||
getGenerationStatusMeta,
|
||||
isReadableGenerationStatus,
|
||||
needsGenerationAttention,
|
||||
} from '../utils/storyStatus'
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -24,6 +29,7 @@ interface StoryItem {
|
||||
created_at: string
|
||||
mode: string
|
||||
generation_status: string
|
||||
text_status: string
|
||||
image_status: string
|
||||
audio_status: string
|
||||
last_error: string | null
|
||||
@@ -35,14 +41,12 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
|
||||
const completedCount = computed(() =>
|
||||
stories.value.filter((story) => story.generation_status === 'completed').length,
|
||||
const readableCount = computed(() =>
|
||||
stories.value.filter((story) => isReadableGenerationStatus(story.generation_status)).length,
|
||||
)
|
||||
|
||||
const attentionCount = computed(() =>
|
||||
stories.value.filter((story) =>
|
||||
['degraded_completed', 'failed'].includes(story.generation_status),
|
||||
).length,
|
||||
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length,
|
||||
)
|
||||
|
||||
async function fetchStories() {
|
||||
@@ -144,12 +148,12 @@ onMounted(() => {
|
||||
<div class="text-gray-500 text-sm mt-1">绘本数量</div>
|
||||
</div>
|
||||
<div class="text-center px-4 py-2">
|
||||
<div class="text-3xl font-bold text-gray-800">{{ completedCount }}</div>
|
||||
<div class="text-gray-500 text-sm mt-1">完整可用</div>
|
||||
<div class="text-3xl font-bold text-gray-800">{{ readableCount }}</div>
|
||||
<div class="text-gray-500 text-sm mt-1">可阅读</div>
|
||||
</div>
|
||||
<div class="text-center px-4 py-2">
|
||||
<div class="text-3xl font-bold text-gray-800">{{ attentionCount }}</div>
|
||||
<div class="text-gray-500 text-sm mt-1">待补资源</div>
|
||||
<div class="text-gray-500 text-sm mt-1">需关注</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import ConfirmModal from '../components/ui/ConfirmModal.vue'
|
||||
import GenerationTrace from '../components/GenerationTrace.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ interface Story {
|
||||
image_url: string | null
|
||||
mode: string
|
||||
generation_status: string
|
||||
text_status: string
|
||||
image_status: string
|
||||
audio_status: string
|
||||
last_error: string | null
|
||||
@@ -49,6 +51,7 @@ const audioProgress = ref(0)
|
||||
const audioDuration = ref(0)
|
||||
const error = ref('')
|
||||
const showDeleteConfirm = ref(false)
|
||||
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
|
||||
|
||||
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? [])
|
||||
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
|
||||
@@ -62,6 +65,10 @@ const assetGuidance = computed(() => {
|
||||
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
|
||||
}
|
||||
|
||||
if (story.value?.generation_status === 'partial_ready') {
|
||||
return '正文已经可读,仍可继续补全封面或音频。'
|
||||
}
|
||||
|
||||
if (story.value?.generation_status === 'assets_generating') {
|
||||
return '资源正在处理中,可以稍后刷新查看最新状态。'
|
||||
}
|
||||
@@ -103,6 +110,7 @@ async function generateImage() {
|
||||
story.value = await api.post<Story>(`/api/generations/${story.value.id}/retry-assets`, {
|
||||
assets: ['image'],
|
||||
})
|
||||
await generationTraceRef.value?.refresh()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '图片生成失败'
|
||||
await refreshStorySnapshot().catch(() => undefined)
|
||||
@@ -151,6 +159,7 @@ async function retryAudio() {
|
||||
audioUrl.value = null
|
||||
await loadAudio()
|
||||
}
|
||||
await generationTraceRef.value?.refresh()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '音频生成失败'
|
||||
await refreshStorySnapshot().catch(() => undefined)
|
||||
@@ -338,6 +347,12 @@ onUnmounted(() => {
|
||||
<p>{{ assetGuidance }}</p>
|
||||
</div>
|
||||
|
||||
<GenerationTrace
|
||||
ref="generationTraceRef"
|
||||
class="mb-10"
|
||||
:story-id="story.id"
|
||||
/>
|
||||
|
||||
<div class="prose prose-lg max-w-none mb-10">
|
||||
<p
|
||||
v-for="(paragraph, index) in storyParagraphs"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import { useStorybookStore } from '../stores/storybook'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import GenerationTrace from '../components/GenerationTrace.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ interface StoryDetailResponse {
|
||||
image_url: string | null
|
||||
mode: string
|
||||
generation_status: string
|
||||
text_status: string
|
||||
image_status: string
|
||||
audio_status: string
|
||||
last_error: string | null
|
||||
@@ -45,6 +47,7 @@ const loading = ref(true)
|
||||
const imageLoading = ref(false)
|
||||
const error = ref('')
|
||||
const currentPageIndex = ref(-1)
|
||||
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
|
||||
|
||||
const totalPages = computed(() => storybook.value?.pages.length || 0)
|
||||
const isCover = computed(() => currentPageIndex.value === -1)
|
||||
@@ -83,6 +86,7 @@ const currentStoryId = computed(() => {
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
})
|
||||
const storybookTraceId = computed(() => storybook.value?.id ?? currentStoryId.value)
|
||||
|
||||
function goHome() {
|
||||
store.clearStorybook()
|
||||
@@ -122,6 +126,7 @@ async function loadStorybook() {
|
||||
|
||||
if (cachedStorybook?.id === storyId) {
|
||||
loading.value = false
|
||||
await generationTraceRef.value?.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -150,6 +155,7 @@ async function loadStorybook() {
|
||||
last_error: detail.last_error,
|
||||
retryable_assets: detail.retryable_assets,
|
||||
})
|
||||
await generationTraceRef.value?.refresh()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '绘本加载失败'
|
||||
} finally {
|
||||
@@ -186,6 +192,7 @@ async function retryStorybookImages() {
|
||||
last_error: detail.last_error,
|
||||
retryable_assets: detail.retryable_assets,
|
||||
})
|
||||
await generationTraceRef.value?.refresh()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '插图补全失败'
|
||||
await loadStorybook().catch(() => undefined)
|
||||
@@ -394,6 +401,17 @@ watch(
|
||||
读完了,再来一本
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<section class="bg-[#0D0F1A] px-4 pb-10 md:px-8">
|
||||
<GenerationTrace
|
||||
ref="generationTraceRef"
|
||||
:story-id="storybookTraceId"
|
||||
tone="dark"
|
||||
title="绘本生成轨迹"
|
||||
description="绘本正文、封面和每页插图都会写入事件流;失败时可以看到资源重试和 Provider 调用结果。"
|
||||
class="mx-auto max-w-5xl"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user