feat: persist story generation states and cache audio
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled

This commit is contained in:
2026-04-17 17:14:09 +08:00
parent 145be0e67b
commit a97a2fe005
17 changed files with 2045 additions and 849 deletions

View File

@@ -1,14 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface StorybookPage {
page_number: number
text: string
image_prompt: string
image_url?: string
}
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface StorybookPage {
page_number: number
text: string
image_prompt: string
image_url?: string
}
export interface Storybook {
id?: number // 新增
title: string
@@ -17,22 +17,26 @@ export interface Storybook {
pages: StorybookPage[]
cover_prompt: string
cover_url?: string
generation_status?: string
image_status?: string
audio_status?: string
last_error?: string | null
}
export const useStorybookStore = defineStore('storybook', () => {
const currentStorybook = ref<Storybook | null>(null)
function setStorybook(storybook: Storybook) {
currentStorybook.value = storybook
}
function clearStorybook() {
currentStorybook.value = null
}
return {
currentStorybook,
setStorybook,
clearStorybook,
}
})
export const useStorybookStore = defineStore('storybook', () => {
const currentStorybook = ref<Storybook | null>(null)
function setStorybook(storybook: Storybook) {
currentStorybook.value = storybook
}
function clearStorybook() {
currentStorybook.value = null
}
return {
currentStorybook,
setStorybook,
clearStorybook,
}
})

View File

@@ -0,0 +1,79 @@
export type StoryGenerationStatus =
| 'narrative_ready'
| 'assets_generating'
| 'completed'
| 'degraded_completed'
| 'failed'
export type StoryAssetStatus =
| 'not_requested'
| 'generating'
| 'ready'
| 'failed'
interface StatusMeta {
label: string
description: string
badgeClass: string
}
const generationStatusMetaMap: Record<StoryGenerationStatus, StatusMeta> = {
narrative_ready: {
label: '文本已完成',
description: '故事内容已经生成,可以继续补充封面或音频。',
badgeClass: 'bg-sky-50 text-sky-700 border border-sky-100',
},
assets_generating: {
label: '资源生成中',
description: '封面或音频正在生成中,请稍候查看结果。',
badgeClass: 'bg-amber-50 text-amber-700 border border-amber-100',
},
completed: {
label: '内容可用',
description: '当前内容已经达到可阅读状态。',
badgeClass: 'bg-emerald-50 text-emerald-700 border border-emerald-100',
},
degraded_completed: {
label: '部分降级完成',
description: '核心内容可用,但有部分资源生成失败。',
badgeClass: 'bg-orange-50 text-orange-700 border border-orange-100',
},
failed: {
label: '生成失败',
description: '当前内容还未成功生成,请稍后重试。',
badgeClass: 'bg-rose-50 text-rose-700 border border-rose-100',
},
}
const assetStatusMetaMap: Record<StoryAssetStatus, StatusMeta> = {
not_requested: {
label: '未请求',
description: '还没有发起该资源生成。',
badgeClass: 'bg-slate-100 text-slate-600 border border-slate-200',
},
generating: {
label: '生成中',
description: '资源正在生成,请稍候。',
badgeClass: 'bg-amber-50 text-amber-700 border border-amber-100',
},
ready: {
label: '已就绪',
description: '该资源可使用。',
badgeClass: 'bg-emerald-50 text-emerald-700 border border-emerald-100',
},
failed: {
label: '失败',
description: '最近一次生成失败,可以稍后重试。',
badgeClass: 'bg-rose-50 text-rose-700 border border-rose-100',
},
}
export function getGenerationStatusMeta(status?: string): StatusMeta {
return generationStatusMetaMap[(status ?? 'narrative_ready') as StoryGenerationStatus]
?? generationStatusMetaMap.narrative_ready
}
export function getAssetStatusMeta(status?: string): StatusMeta {
return assetStatusMetaMap[(status ?? 'not_requested') as StoryAssetStatus]
?? assetStatusMetaMap.not_requested
}

View File

@@ -1,19 +1,20 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import CreateStoryModal from '../components/CreateStoryModal.vue'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import CreateStoryModal from '../components/CreateStoryModal.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
BookOpenIcon,
ChevronRightIcon,
ExclamationCircleIcon,
PhotoIcon,
SparklesIcon,
PlusIcon,
SparklesIcon,
} from '@heroicons/vue/24/outline'
interface StoryItem {
@@ -21,6 +22,11 @@ interface StoryItem {
title: string
image_url: string | null
created_at: string
mode: string
generation_status: string
image_status: string
audio_status: string
last_error: string | null
}
const router = useRouter()
@@ -29,6 +35,16 @@ const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
const completedCount = computed(() =>
stories.value.filter((story) => story.generation_status === 'completed').length,
)
const attentionCount = computed(() =>
stories.value.filter((story) =>
['degraded_completed', 'failed'].includes(story.generation_status),
).length,
)
async function fetchStories() {
try {
stories.value = await api.get<StoryItem[]>('/api/stories')
@@ -60,8 +76,13 @@ function goToCreate() {
showCreateModal.value = true
}
function getStoryLink(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
}
onMounted(() => {
fetchStories()
void fetchStories()
if (router.currentRoute.value.query.openCreate) {
showCreateModal.value = true
router.replace({ query: { ...router.currentRoute.value.query, openCreate: undefined } })
@@ -71,11 +92,10 @@ onMounted(() => {
<template>
<div class="max-w-6xl mx-auto px-4">
<!-- 页面标题 -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">我的故事</h1>
<p class="text-gray-500">收藏的所有童话故事</p>
<p class="text-gray-500">回看每个作品的生成质量资源状态和可优化点</p>
</div>
<BaseButton @click="goToCreate">
<SparklesIcon class="h-5 w-5 mr-2" />
@@ -83,12 +103,10 @@ onMounted(() => {
</BaseButton>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="py-20">
<LoadingSpinner text="加载中..." />
<LoadingSpinner text="正在加载内容..." />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
@@ -97,54 +115,53 @@ onMounted(() => {
/>
</div>
<!-- 空状态 -->
<div v-else-if="stories.length === 0" class="py-10">
<EmptyState
:icon="BookOpenIcon"
title="开始你的创作之旅"
description="还没有创作任何故事,现在就开始为孩子创作第一个专属童话故事吧!"
title="从第一个作品开始"
description="现在还没有故事或绘本,先做一个能完整跑通的版本,后面再持续优化。"
>
<template #action>
<BaseButton @click="goToCreate">
<PlusIcon class="h-5 w-5 mr-2" />
创作第一个故事
创作第一个作品
</BaseButton>
</template>
</EmptyState>
</div>
<!-- 故事列表 -->
<template v-else>
<!-- 统计卡片 -->
<BaseCard class="mb-8" padding="lg">
<div class="flex items-center justify-around divide-x divide-gray-100">
<div class="text-center px-4">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ stories.length }}</div>
<div class="text-gray-500 text-sm mt-1">故事总数</div>
<div class="text-gray-500 text-sm mt-1">内容总数</div>
</div>
<div class="text-center px-4">
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">
{{ stories.filter(s => s.image_url).length }}
{{ stories.filter((story) => story.mode === 'storybook').length }}
</div>
<div class="text-gray-500 text-sm mt-1">已配图</div>
<div class="text-gray-500 text-sm mt-1">绘本数量</div>
</div>
<div class="text-center px-4">
<BookOpenIcon class="h-8 w-8 text-purple-500 mx-auto" />
<div class="text-gray-500 text-sm mt-1">继续阅读</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>
<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>
</div>
</BaseCard>
<!-- 故事网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link
v-for="story in stories"
:key="story.id"
:to="`/story/${story.id}`"
:to="getStoryLink(story)"
class="block group"
>
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
<!-- 封面图 -->
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
<img
v-if="story.image_url"
@@ -159,23 +176,64 @@ onMounted(() => {
<PhotoIcon class="h-12 w-12" />
</div>
<!-- 悬停阅读提示 -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span class="inline-flex items-center gap-1 px-4 py-2 bg-white/90 text-gray-900 rounded-full font-medium shadow-lg backdrop-blur-sm transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
阅读故事 <ChevronRightIcon class="h-4 w-4" />
</span>
<div class="absolute top-4 left-4 flex flex-wrap gap-2">
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="story.mode === 'storybook' ? 'bg-amber-100/90 text-amber-800' : 'bg-violet-100/90 text-violet-800'"
>
{{ story.mode === 'storybook' ? '绘本' : '故事' }}
</span>
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="getGenerationStatusMeta(story.generation_status).badgeClass"
>
{{ getGenerationStatusMeta(story.generation_status).label }}
</span>
</div>
<div class="absolute inset-0 bg-black/35 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span class="inline-flex items-center gap-1 px-4 py-2 bg-white/90 text-gray-900 rounded-full font-medium shadow-lg backdrop-blur-sm transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
{{ story.mode === 'storybook' ? '阅读绘本' : '阅读故事' }}
<ChevronRightIcon class="h-4 w-4" />
</span>
</div>
</div>
<!-- 信息区 -->
<div class="p-5 flex-1 flex flex-col">
<h3 class="font-bold text-xl text-gray-800 mb-2 line-clamp-2 group-hover:text-purple-600 transition-colors">
<h3 class="font-bold text-xl text-gray-800 mb-3 line-clamp-2 group-hover:text-purple-600 transition-colors">
{{ story.title }}
</h3>
<p class="text-sm text-gray-500 mb-4 leading-6">
{{ getGenerationStatusMeta(story.generation_status).description }}
</p>
<div class="flex flex-wrap gap-2 mb-4">
<span
class="px-2 py-1 rounded-lg text-xs font-medium"
:class="getAssetStatusMeta(story.image_status).badgeClass"
>
封面{{ getAssetStatusMeta(story.image_status).label }}
</span>
<span
class="px-2 py-1 rounded-lg text-xs font-medium"
:class="getAssetStatusMeta(story.audio_status).badgeClass"
>
音频{{ getAssetStatusMeta(story.audio_status).label }}
</span>
</div>
<div
v-if="story.last_error"
class="mb-4 px-3 py-2 rounded-xl bg-amber-50 text-amber-700 text-sm line-clamp-2"
>
{{ story.last_error }}
</div>
<div class="mt-auto flex items-center justify-between text-sm text-gray-500">
<span>{{ formatDate(story.created_at) }}</span>
<span v-if="story.image_url" class="flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded text-xs font-medium">
已配图
<span>
{{ story.image_url ? '已有封面' : '待补封面' }}
</span>
</div>
</div>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
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 LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
ArrowLeftIcon,
ExclamationTriangleIcon,
@@ -12,21 +13,38 @@ import {
SpeakerWaveIcon,
SparklesIcon,
TrashIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline'
const route = useRoute()
const router = useRouter()
interface Story {
id: number
title: string
story_text: string
story_text: string | null
cover_prompt: string | null
image_url: string | null
mode: string
generation_status: string
image_status: string
audio_status: string
last_error: string | null
pages?: Array<{
page_number: number
text: string
image_prompt: string
image_url?: string | null
}> | null
}
interface ImageGenerationResponse {
image_url: string | null
generation_status: string
image_status: string
audio_status: string
last_error: string | null
}
const route = useRoute()
const router = useRouter()
const story = ref<Story | null>(null)
const loading = ref(true)
const imageLoading = ref(false)
@@ -38,11 +56,29 @@ const audioProgress = ref(0)
const audioDuration = ref(0)
const error = ref('')
const showDeleteConfirm = ref(false)
const imageGenerationFailed = ref(false)
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? [])
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
async function refreshStorySnapshot() {
const data = await api.get<Story>(`/api/stories/${route.params.id}`)
if (data.mode === 'storybook') {
await router.replace(`/storybook/view/${data.id}`)
return
}
story.value = data
}
async function fetchStory() {
loading.value = true
error.value = ''
try {
story.value = await api.get<Story>(`/api/stories/${route.params.id}`)
await refreshStorySnapshot()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -52,12 +88,20 @@ async function fetchStory() {
async function generateImage() {
if (!story.value) return
imageLoading.value = true
error.value = ''
try {
const result = await api.post<{ image_url: string }>(`/api/image/generate/${story.value.id}`)
const result = await api.post<ImageGenerationResponse>(`/api/image/generate/${story.value.id}`)
story.value.image_url = result.image_url
story.value.generation_status = result.generation_status
story.value.image_status = result.image_status
story.value.audio_status = result.audio_status
story.value.last_error = result.last_error
} catch (e) {
error.value = e instanceof Error ? e.message : '图片生成失败'
await refreshStorySnapshot().catch(() => undefined)
} finally {
imageLoading.value = false
}
@@ -65,16 +109,25 @@ async function generateImage() {
async function loadAudio() {
if (!story.value || audioUrl.value) return
audioLoading.value = true
error.value = ''
try {
const response = await fetch(`/api/audio/${story.value.id}`, {
credentials: 'include',
})
if (!response.ok) throw new Error('音频加载失败')
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: '音频加载失败' }))
throw new Error(payload.detail || '音频加载失败')
}
const blob = await response.blob()
audioUrl.value = URL.createObjectURL(blob)
await refreshStorySnapshot().catch(() => undefined)
} catch (e) {
error.value = e instanceof Error ? e.message : '音频加载失败'
await refreshStorySnapshot().catch(() => undefined)
} finally {
audioLoading.value = false
}
@@ -82,24 +135,29 @@ async function loadAudio() {
function togglePlay() {
if (!audioRef.value) return
if (isPlaying.value) {
audioRef.value.pause()
} else {
audioRef.value.play()
void audioRef.value.play()
}
isPlaying.value = !isPlaying.value
}
function updateProgress() {
if (!audioRef.value) return
audioProgress.value = audioRef.value.currentTime
audioDuration.value = audioRef.value.duration || 0
}
function seekAudio(e: MouseEvent) {
function seekAudio(event: MouseEvent) {
if (!audioRef.value || !audioDuration.value) return
const rect = (e.target as HTMLElement).getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const percent = (event.clientX - rect.left) / rect.width
audioRef.value.currentTime = percent * audioDuration.value
}
@@ -111,9 +169,10 @@ function formatTime(seconds: number) {
async function deleteStory() {
if (!story.value) return
try {
await api.delete(`/api/stories/${story.value.id}`)
router.push('/my-stories')
await router.push('/my-stories')
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
@@ -124,12 +183,14 @@ async function confirmDelete() {
await deleteStory()
}
onMounted(() => {
fetchStory()
if (route.query.imageError === '1') {
imageGenerationFailed.value = true
}
})
watch(
() => route.params.id,
() => {
story.value = null
void fetchStory()
},
{ immediate: true },
)
onUnmounted(() => {
if (audioUrl.value) {
@@ -139,7 +200,7 @@ onUnmounted(() => {
</script>
<template>
<div class="max-w-4xl mx-auto px-4">
<div class="max-w-5xl mx-auto px-4">
<div v-if="loading" class="py-20">
<LoadingSpinner size="lg" text="正在加载故事..." />
</div>
@@ -156,29 +217,16 @@ onUnmounted(() => {
返回
</BaseButton>
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
<div
v-if="story.last_error"
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-2xl flex items-start gap-3"
>
<div
v-if="imageGenerationFailed && !story?.image_url"
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-xl flex items-center justify-between"
>
<div class="flex items-center space-x-2">
<ExclamationTriangleIcon class="h-5 w-5" />
<span>封面生成失败您可以稍后重试</span>
</div>
<BaseButton
variant="ghost"
size="sm"
class="text-amber-500 hover:text-amber-700"
@click="imageGenerationFailed = false"
>
<XMarkIcon class="h-5 w-5" />
</BaseButton>
<ExclamationTriangleIcon class="h-5 w-5 mt-0.5 flex-shrink-0" />
<div>
<div class="font-semibold mb-1">最近一次资源生成异常</div>
<p class="text-sm leading-6">{{ story.last_error }}</p>
</div>
</Transition>
</div>
<div class="glass rounded-3xl shadow-2xl overflow-hidden">
<div class="relative aspect-[21/9] bg-gradient-to-br from-purple-100 via-pink-100 to-blue-100 overflow-hidden">
@@ -188,28 +236,60 @@ onUnmounted(() => {
:alt="story.title"
class="w-full h-full object-cover"
/>
<div v-else class="absolute inset-0 flex flex-col items-center justify-center">
<div v-else class="absolute inset-0 flex flex-col items-center justify-center px-6 text-center">
<PhotoIcon class="h-16 w-16 text-purple-400 mb-4" />
<p class="text-gray-600 mb-4 max-w-md">
{{ imageMeta.description }}
</p>
<BaseButton
v-if="story.cover_prompt"
variant="secondary"
:loading="imageLoading"
@click="generateImage"
>
<template v-if="imageLoading">AI 正在绘制...</template>
<template v-else>生成精美封面</template>
<template v-if="imageLoading">正在生成封面...</template>
<template v-else>{{ story.image_status === 'failed' ? '重试生成封面' : '生成封面' }}</template>
</BaseButton>
</div>
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white/80 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white/80 to-transparent" />
</div>
<div class="p-8 md:p-12 -mt-16 relative">
<h1 class="text-3xl md:text-4xl font-bold gradient-text mb-8 leading-tight">
{{ story.title }}
</h1>
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
<h1 class="text-3xl md:text-4xl font-bold gradient-text leading-tight">
{{ story.title }}
</h1>
<span
class="px-3 py-1.5 rounded-full text-sm font-medium"
:class="generationMeta.badgeClass"
>
{{ generationMeta.label }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-10">
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">整体状态</div>
<div class="font-semibold text-gray-800 mb-2">{{ generationMeta.label }}</div>
<p class="text-sm text-gray-500 leading-6">{{ generationMeta.description }}</p>
</div>
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">封面资源</div>
<div class="font-semibold text-gray-800 mb-2">{{ imageMeta.label }}</div>
<p class="text-sm text-gray-500 leading-6">{{ imageMeta.description }}</p>
</div>
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">音频资源</div>
<div class="font-semibold text-gray-800 mb-2">{{ audioMeta.label }}</div>
<p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</p>
</div>
</div>
<div class="prose prose-lg max-w-none mb-10">
<p
v-for="(paragraph, index) in story.story_text.split('\n\n')"
v-for="(paragraph, index) in storyParagraphs"
:key="index"
class="text-gray-700 leading-loose mb-6 first-letter:text-4xl first-letter:font-bold first-letter:text-purple-600 first-letter:float-left first-letter:mr-2"
>
@@ -224,10 +304,10 @@ onUnmounted(() => {
@click="loadAudio"
class="mx-auto"
>
<template v-if="audioLoading">加载中...</template>
<template v-if="audioLoading">正在准备音频...</template>
<template v-else>
<SpeakerWaveIcon class="h-5 w-5" />
听故事
听故事
</template>
</BaseButton>
</div>
@@ -247,10 +327,10 @@ onUnmounted(() => {
@click="togglePlay"
>
<svg v-if="!isPlaying" class="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
</BaseButton>
@@ -262,7 +342,7 @@ onUnmounted(() => {
<div
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 rounded-full transition-all duration-100"
:style="{ width: `${(audioProgress / audioDuration) * 100 || 0}%` }"
></div>
/>
</div>
<div class="flex justify-between text-sm text-gray-500 mt-1">
<span>{{ formatTime(audioProgress) }}</span>
@@ -301,7 +381,7 @@ onUnmounted(() => {
<ConfirmModal
:show="showDeleteConfirm"
title="确定删除这个故事吗?"
message="删除后将无法恢复"
message="删除后将无法恢复"
confirm-text="确定删除"
cancel-text="取消"
variant="danger"

View File

@@ -1,34 +1,86 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import { useStorybookStore } from '../stores/storybook'
import BaseButton from '../components/ui/BaseButton.vue'
import {
ArrowLeftIcon,
ArrowRightIcon,
HomeIcon,
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
ArrowLeftIcon,
ArrowRightIcon,
BookOpenIcon,
ExclamationTriangleIcon,
HomeIcon,
PhotoIcon,
SparklesIcon,
PhotoIcon
} from '@heroicons/vue/24/outline'
interface StoryDetailResponse {
id: number
title: string
story_text: string | null
pages: Array<{
page_number: number
text: string
image_prompt: string
image_url?: string | null
}> | null
cover_prompt: string | null
image_url: string | null
mode: string
generation_status: string
image_status: string
audio_status: string
last_error: string | null
}
const route = useRoute()
const router = useRouter()
const store = useStorybookStore()
const storybook = computed(() => store.currentStorybook)
const loading = ref(true)
const error = ref('')
const currentPageIndex = ref(-1)
const currentPageIndex = ref(-1) // -1 for cover
// 计算属性
const totalPages = computed(() => storybook.value?.pages.length || 0)
const isCover = computed(() => currentPageIndex.value === -1)
const isLastPage = computed(() => currentPageIndex.value === totalPages.value - 1)
const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status))
const currentPage = computed(() => {
if (!storybook.value || isCover.value) return null
return storybook.value.pages[currentPageIndex.value]
})
const pageImageMessage = computed(() => {
if (storybook.value?.image_status === 'failed') {
return '部分插图生成失败,但不影响继续阅读。'
}
if (storybook.value?.image_status === 'generating') {
return '插图正在生成中,请稍后刷新查看。'
}
if (storybook.value?.image_status === 'not_requested') {
return '当前绘本还没有发起插图生成。'
}
return currentPage.value?.image_prompt
? `${currentPage.value.image_prompt}`
: '当前页插图暂未就绪。'
})
const currentStoryId = computed(() => {
const rawId = route.params.id
const normalized = Array.isArray(rawId) ? rawId[0] : rawId
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
})
// 导航
function goHome() {
store.clearStorybook()
router.push('/')
@@ -46,16 +98,89 @@ function prevPage() {
}
}
onMounted(() => {
if (!storybook.value) {
router.push('/')
async function loadStorybook() {
loading.value = true
error.value = ''
currentPageIndex.value = -1
const storyId = currentStoryId.value
const cachedStorybook = store.currentStorybook
if (storyId === null) {
if (cachedStorybook) {
loading.value = false
return
}
await router.replace('/')
loading.value = false
return
}
})
if (cachedStorybook?.id === storyId) {
loading.value = false
return
}
try {
const detail = await api.get<StoryDetailResponse>(`/api/stories/${storyId}`)
if (detail.mode !== 'storybook') {
await router.replace(`/story/${detail.id}`)
return
}
store.setStorybook({
id: detail.id,
title: detail.title,
main_character: cachedStorybook?.id === detail.id ? cachedStorybook.main_character : '故事主角',
art_style: cachedStorybook?.id === detail.id ? cachedStorybook.art_style : 'AI 绘本风格',
pages: (detail.pages ?? []).map((page) => ({
...page,
image_url: page.image_url ?? undefined,
})),
cover_prompt: detail.cover_prompt ?? '',
cover_url: detail.image_url ?? undefined,
generation_status: detail.generation_status,
image_status: detail.image_status,
audio_status: detail.audio_status,
last_error: detail.last_error,
})
} catch (e) {
error.value = e instanceof Error ? e.message : '绘本加载失败'
} finally {
loading.value = false
}
}
watch(
() => route.params.id,
() => {
void loadStorybook()
},
{ immediate: true },
)
</script>
<template>
<div class="storybook-viewer" v-if="storybook">
<!-- 导航栏 -->
<div v-if="loading" class="h-screen bg-[#0D0F1A] flex items-center justify-center px-4">
<LoadingSpinner size="lg" text="正在载入绘本..." />
</div>
<div v-else-if="error" class="h-screen bg-[#0D0F1A] flex items-center justify-center px-4">
<div class="max-w-md text-center text-white">
<ExclamationTriangleIcon class="w-14 h-14 mx-auto mb-4 text-amber-400" />
<p class="text-lg mb-6">{{ error }}</p>
<div class="flex items-center justify-center gap-3">
<BaseButton @click="loadStorybook">重新加载</BaseButton>
<BaseButton variant="ghost" class="text-white border-white/20 hover:bg-white/10" @click="goHome">
返回首页
</BaseButton>
</div>
</div>
</div>
<div v-else-if="storybook" class="storybook-viewer">
<nav class="fixed top-0 left-0 right-0 z-50 p-4 flex justify-between items-center bg-gradient-to-b from-black/50 to-transparent">
<button @click="goHome" class="p-2 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all">
<HomeIcon class="w-6 h-6" />
@@ -63,121 +188,139 @@ onMounted(() => {
<div class="text-white font-serif text-lg text-shadow">
{{ storybook.title }}
</div>
<div class="w-10"></div> <!-- 占位 -->
<div class="w-10"></div>
</nav>
<!-- 主展示区 -->
<div class="h-screen w-full flex items-center justify-center p-4 md:p-8 relative overflow-hidden">
<!-- 动态背景 -->
<div class="absolute inset-0 bg-[#0D0F1A] z-0">
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
<div class="stars"></div>
</div>
<!-- 书页容器 -->
<div class="book-container relative z-10 w-full max-w-5xl aspect-[16/10] bg-[#fffbf0] rounded-2xl shadow-2xl overflow-hidden flex transition-all duration-500">
<!-- 封面模式 -->
<div v-if="isCover" class="w-full h-full flex flex-col md:flex-row animate-fade-in">
<!-- 封面图 -->
<div class="w-full md:w-1/2 h-1/2 md:h-full relative overflow-hidden bg-gray-900 group">
<template v-if="storybook.cover_url">
<img :src="storybook.cover_url" class="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" />
</template>
<div v-else class="w-full h-full flex flex-col items-center justify-center p-8 text-center bg-gradient-to-br from-indigo-900 to-purple-900 text-white">
<SparklesIcon class="w-20 h-20 mb-4 opacity-50" />
<p class="text-white/60 text-sm">封面正在构思中...</p>
<SparklesIcon class="w-20 h-20 mb-4 opacity-50" />
<p class="text-white/70 text-sm max-w-xs leading-6">{{ imageMeta.description }}</p>
</div>
<!-- 封面遮罩 -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent md:bg-gradient-to-r"></div>
<div class="absolute bottom-6 left-6 text-white md:hidden">
<span class="inline-block px-3 py-1 bg-yellow-500/90 rounded-full text-xs font-bold mb-2 text-black">绘本故事</span>
<span class="inline-block px-3 py-1 bg-yellow-500/90 rounded-full text-xs font-bold mb-2 text-black">绘本故事</span>
</div>
</div>
<!-- 封面信息 -->
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex flex-col justify-center bg-[#fffbf0] text-amber-900">
<div class="hidden md:block mb-8">
<span class="inline-block px-4 py-1 border border-amber-900/30 rounded-full text-sm tracking-widest uppercase">Original Storybook</span>
</div>
<h1 class="text-4xl md:text-6xl font-serif font-bold mb-6 leading-tight">{{ storybook.title }}</h1>
<div class="space-y-4 mb-10 text-amber-900/70">
<p class="flex items-center"><span class="w-20 font-bold opacity-50">主角</span> {{ storybook.main_character }}</p>
<p class="flex items-center"><span class="w-20 font-bold opacity-50">画风</span> {{ storybook.art_style }}</p>
<p class="flex items-center"><span class="w-20 font-bold opacity-50">主角</span> {{ storybook.main_character || '故事主角' }}</p>
<p class="flex items-center"><span class="w-20 font-bold opacity-50">画风</span> {{ storybook.art_style || 'AI 绘本风格' }}</p>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<span class="px-3 py-1 rounded-full text-xs font-semibold" :class="generationMeta.badgeClass">
{{ generationMeta.label }}
</span>
<span class="px-3 py-1 rounded-full text-xs font-semibold" :class="imageMeta.badgeClass">
插图{{ imageMeta.label }}
</span>
<span class="px-3 py-1 rounded-full text-xs font-semibold" :class="audioMeta.badgeClass">
音频{{ audioMeta.label }}
</span>
</div>
<p class="text-sm leading-6 text-amber-900/70 mb-6">
{{ generationMeta.description }}
</p>
<div
v-if="storybook.last_error"
class="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
<div class="font-semibold mb-1">最近一次资源异常</div>
<p class="leading-6">{{ storybook.last_error }}</p>
</div>
<BaseButton size="lg" @click="nextPage" class="self-start shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all">
开始阅读 <BookOpenIcon class="w-5 h-5 ml-2" />
开始阅读
<BookOpenIcon class="w-5 h-5 ml-2" />
</BaseButton>
</div>
</div>
<!-- 内页模式 -->
<div v-else class="w-full h-full flex flex-col md:flex-row animate-fade-in relative">
<!-- 页码 -->
<div class="absolute bottom-4 right-6 text-amber-900/30 font-serif text-xl z-20">
{{ currentPageIndex + 1 }} / {{ totalPages }}
</div>
<!-- 插图区域 () -->
<div class="w-full md:w-1/2 h-1/2 md:h-full relative bg-gray-100 border-r border-amber-900/5">
<template v-if="currentPage?.image_url">
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
</template>
<div v-else class="w-full h-full flex items-center justify-center p-10 bg-white">
<div class="text-center">
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
<PhotoIcon class="w-10 h-10 text-amber-300" />
</div>
<p class="text-amber-900/40 text-sm max-w-xs mx-auto italic">"{{ currentPage?.image_prompt }}"</p>
<template v-if="currentPage?.image_url">
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
</template>
<div v-else class="w-full h-full flex items-center justify-center p-10 bg-white">
<div class="text-center">
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
<PhotoIcon class="w-10 h-10 text-amber-300" />
</div>
</div>
<p class="text-amber-900/50 text-sm max-w-xs mx-auto italic leading-6">
{{ pageImageMessage }}
</p>
<p
v-if="storybook.last_error && storybook.image_status === 'failed'"
class="mt-3 text-xs font-medium text-amber-700"
>
可以先继续阅读稍后再回来看插图结果
</p>
</div>
</div>
</div>
<!-- 文字区域 () -->
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex items-center justify-center bg-[#fffbf0]">
<div class="prose prose-xl prose-amber font-serif text-amber-900 leading-relaxed text-center md:text-left">
<p>{{ currentPage?.text }}</p>
</div>
<div class="prose prose-xl prose-amber font-serif text-amber-900 leading-relaxed text-center md:text-left">
<p>{{ currentPage?.text }}</p>
</div>
</div>
</div>
</div>
<!-- 翻页控制 (悬浮) -->
<button
<button
v-if="!isCover"
@click="prevPage"
@click="prevPage"
class="fixed left-4 md:left-8 top-1/2 -translate-y-1/2 p-3 md:p-4 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all disabled:opacity-30"
>
<ArrowLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
</button>
<button
<button
v-if="!isLastPage"
@click="nextPage"
@click="nextPage"
class="fixed right-4 md:right-8 top-1/2 -translate-y-1/2 p-3 md:p-4 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all shadow-lg"
>
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
</button>
<!-- 最后一页的完成按钮 -->
<BaseButton
<BaseButton
v-if="isLastPage"
@click="goHome"
class="fixed right-8 md:right-12 bottom-8 md:bottom-12 shadow-xl"
>
读完了再来一本
</BaseButton>
</div>
</div>
</template>
<style scoped>
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.animate-fade-in {
@@ -190,7 +333,7 @@ onMounted(() => {
}
.book-container {
box-shadow:
box-shadow:
0 20px 50px -12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}