chore: clear lint and sync admin story views
This commit is contained in:
@@ -156,9 +156,10 @@ async function generateStory() {
|
|||||||
|
|
||||||
storybookStore.setStorybook(response)
|
storybookStore.setStorybook(response)
|
||||||
close()
|
close()
|
||||||
router.push('/storybook/view')
|
const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view'
|
||||||
|
router.push(storybookPath)
|
||||||
} else {
|
} else {
|
||||||
const result = await api.post<any>('/api/generate/full', payload)
|
const result = await api.post<any>('/api/stories/generate/full', payload)
|
||||||
const query: Record<string, string> = {}
|
const query: Record<string, string> = {}
|
||||||
if (result.errors && Object.keys(result.errors).length > 0) {
|
if (result.errors && Object.keys(result.errors).length > 0) {
|
||||||
if (result.errors.image) query.imageError = '1'
|
if (result.errors.image) query.imageError = '1'
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const router = createRouter({
|
|||||||
component: () => import('./views/StoryDetail.vue'),
|
component: () => import('./views/StoryDetail.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/storybook/view',
|
path: '/storybook/view/:id?',
|
||||||
name: 'storybook-viewer',
|
name: 'storybook-viewer',
|
||||||
component: () => import('./views/StorybookViewer.vue'),
|
component: () => import('./views/StorybookViewer.vue'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface Storybook {
|
|||||||
pages: StorybookPage[]
|
pages: StorybookPage[]
|
||||||
cover_prompt: string
|
cover_prompt: string
|
||||||
cover_url?: string
|
cover_url?: string
|
||||||
|
generation_status?: string
|
||||||
|
image_status?: string
|
||||||
|
audio_status?: string
|
||||||
|
last_error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStorybookStore = defineStore('storybook', () => {
|
export const useStorybookStore = defineStore('storybook', () => {
|
||||||
|
|||||||
79
admin-frontend/src/utils/storyStatus.ts
Normal file
79
admin-frontend/src/utils/storyStatus.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ interface StoryItem {
|
|||||||
title: string
|
title: string
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
mode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -60,6 +61,10 @@ function goToCreate() {
|
|||||||
showCreateModal.value = true
|
showCreateModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoryPath(story: StoryItem) {
|
||||||
|
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchStories()
|
fetchStories()
|
||||||
if (router.currentRoute.value.query.openCreate) {
|
if (router.currentRoute.value.query.openCreate) {
|
||||||
@@ -140,7 +145,7 @@ onMounted(() => {
|
|||||||
<router-link
|
<router-link
|
||||||
v-for="story in stories"
|
v-for="story in stories"
|
||||||
:key="story.id"
|
:key="story.id"
|
||||||
:to="`/story/${story.id}`"
|
:to="getStoryPath(story)"
|
||||||
class="block group"
|
class="block group"
|
||||||
>
|
>
|
||||||
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
|
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import BaseButton from '../components/ui/BaseButton.vue'
|
import BaseButton from '../components/ui/BaseButton.vue'
|
||||||
import ConfirmModal from '../components/ui/ConfirmModal.vue'
|
import ConfirmModal from '../components/ui/ConfirmModal.vue'
|
||||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||||
|
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
@@ -12,21 +13,30 @@ import {
|
|||||||
SpeakerWaveIcon,
|
SpeakerWaveIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
XMarkIcon,
|
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
interface Story {
|
interface Story {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
story_text: string
|
story_text: string | null
|
||||||
cover_prompt: string | null
|
cover_prompt: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
mode: string
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const story = ref<Story | null>(null)
|
const story = ref<Story | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const imageLoading = ref(false)
|
const imageLoading = ref(false)
|
||||||
@@ -38,11 +48,29 @@ const audioProgress = ref(0)
|
|||||||
const audioDuration = ref(0)
|
const audioDuration = ref(0)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showDeleteConfirm = ref(false)
|
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() {
|
async function fetchStory() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
story.value = await api.get<Story>(`/api/stories/${route.params.id}`)
|
await refreshStorySnapshot()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '加载失败'
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,12 +80,17 @@ async function fetchStory() {
|
|||||||
|
|
||||||
async function generateImage() {
|
async function generateImage() {
|
||||||
if (!story.value) return
|
if (!story.value) return
|
||||||
|
|
||||||
imageLoading.value = true
|
imageLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.post<{ image_url: string }>(`/api/image/generate/${story.value.id}`)
|
story.value = await api.post<Story>(`/api/stories/${story.value.id}/assets/retry`, {
|
||||||
story.value.image_url = result.image_url
|
assets: ['image'],
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '图片生成失败'
|
error.value = e instanceof Error ? e.message : '图片生成失败'
|
||||||
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
} finally {
|
} finally {
|
||||||
imageLoading.value = false
|
imageLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -65,16 +98,25 @@ async function generateImage() {
|
|||||||
|
|
||||||
async function loadAudio() {
|
async function loadAudio() {
|
||||||
if (!story.value || audioUrl.value) return
|
if (!story.value || audioUrl.value) return
|
||||||
|
|
||||||
audioLoading.value = true
|
audioLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audio/${story.value.id}`, {
|
const response = await fetch(`/api/audio/${story.value.id}`, {
|
||||||
credentials: 'include',
|
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()
|
const blob = await response.blob()
|
||||||
audioUrl.value = URL.createObjectURL(blob)
|
audioUrl.value = URL.createObjectURL(blob)
|
||||||
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '音频加载失败'
|
error.value = e instanceof Error ? e.message : '音频加载失败'
|
||||||
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
} finally {
|
} finally {
|
||||||
audioLoading.value = false
|
audioLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -82,24 +124,29 @@ async function loadAudio() {
|
|||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (!audioRef.value) return
|
if (!audioRef.value) return
|
||||||
|
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
audioRef.value.pause()
|
audioRef.value.pause()
|
||||||
} else {
|
} else {
|
||||||
audioRef.value.play()
|
void audioRef.value.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlaying.value = !isPlaying.value
|
isPlaying.value = !isPlaying.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress() {
|
function updateProgress() {
|
||||||
if (!audioRef.value) return
|
if (!audioRef.value) return
|
||||||
|
|
||||||
audioProgress.value = audioRef.value.currentTime
|
audioProgress.value = audioRef.value.currentTime
|
||||||
audioDuration.value = audioRef.value.duration || 0
|
audioDuration.value = audioRef.value.duration || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekAudio(e: MouseEvent) {
|
function seekAudio(event: MouseEvent) {
|
||||||
if (!audioRef.value || !audioDuration.value) return
|
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
|
audioRef.value.currentTime = percent * audioDuration.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +158,10 @@ function formatTime(seconds: number) {
|
|||||||
|
|
||||||
async function deleteStory() {
|
async function deleteStory() {
|
||||||
if (!story.value) return
|
if (!story.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/stories/${story.value.id}`)
|
await api.delete(`/api/stories/${story.value.id}`)
|
||||||
router.push('/my-stories')
|
await router.push('/my-stories')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '删除失败'
|
error.value = e instanceof Error ? e.message : '删除失败'
|
||||||
}
|
}
|
||||||
@@ -124,12 +172,14 @@ async function confirmDelete() {
|
|||||||
await deleteStory()
|
await deleteStory()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch(
|
||||||
fetchStory()
|
() => route.params.id,
|
||||||
if (route.query.imageError === '1') {
|
() => {
|
||||||
imageGenerationFailed.value = true
|
story.value = null
|
||||||
}
|
void fetchStory()
|
||||||
})
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (audioUrl.value) {
|
if (audioUrl.value) {
|
||||||
@@ -139,7 +189,7 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div v-if="loading" class="py-20">
|
||||||
<LoadingSpinner size="lg" text="正在加载故事..." />
|
<LoadingSpinner size="lg" text="正在加载故事..." />
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +197,7 @@ onUnmounted(() => {
|
|||||||
<div v-else-if="error && !story" class="text-center py-20">
|
<div v-else-if="error && !story" class="text-center py-20">
|
||||||
<ExclamationTriangleIcon class="h-14 w-14 text-red-400 mx-auto mb-4" />
|
<ExclamationTriangleIcon class="h-14 w-14 text-red-400 mx-auto mb-4" />
|
||||||
<p class="text-red-500 text-lg mb-6">{{ error }}</p>
|
<p class="text-red-500 text-lg mb-6">{{ error }}</p>
|
||||||
<BaseButton @click="router.push('/')">返回首页</BaseButton>
|
<BaseButton @click="router.push('/my-stories')">返回故事库</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="story" class="space-y-8">
|
<div v-else-if="story" class="space-y-8">
|
||||||
@@ -156,29 +206,16 @@ onUnmounted(() => {
|
|||||||
返回
|
返回
|
||||||
</BaseButton>
|
</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
|
<div
|
||||||
v-if="imageGenerationFailed && !story?.image_url"
|
v-if="story.last_error"
|
||||||
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-xl flex items-center justify-between"
|
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-2xl flex items-start gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-2">
|
<ExclamationTriangleIcon class="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||||
<ExclamationTriangleIcon class="h-5 w-5" />
|
<div>
|
||||||
<span>封面生成失败,您可以稍后重试</span>
|
<div class="font-semibold mb-1">最近一次资源生成异常</div>
|
||||||
|
<p class="text-sm leading-6">{{ story.last_error }}</p>
|
||||||
</div>
|
</div>
|
||||||
<BaseButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="text-amber-500 hover:text-amber-700"
|
|
||||||
@click="imageGenerationFailed = false"
|
|
||||||
>
|
|
||||||
<XMarkIcon class="h-5 w-5" />
|
|
||||||
</BaseButton>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<div class="glass rounded-3xl shadow-2xl overflow-hidden">
|
<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">
|
<div class="relative aspect-[21/9] bg-gradient-to-br from-purple-100 via-pink-100 to-blue-100 overflow-hidden">
|
||||||
@@ -188,28 +225,60 @@ onUnmounted(() => {
|
|||||||
:alt="story.title"
|
:alt="story.title"
|
||||||
class="w-full h-full object-cover"
|
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" />
|
<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
|
<BaseButton
|
||||||
|
v-if="story.cover_prompt"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:loading="imageLoading"
|
:loading="imageLoading"
|
||||||
@click="generateImage"
|
@click="generateImage"
|
||||||
>
|
>
|
||||||
<template v-if="imageLoading">AI 正在绘制...</template>
|
<template v-if="imageLoading">正在生成封面...</template>
|
||||||
<template v-else>生成精美封面</template>
|
<template v-else>{{ story.image_status === 'failed' ? '重试生成封面' : '生成封面' }}</template>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="p-8 md:p-12 -mt-16 relative">
|
<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">
|
<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 }}
|
{{ story.title }}
|
||||||
</h1>
|
</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">
|
<div class="prose prose-lg max-w-none mb-10">
|
||||||
<p
|
<p
|
||||||
v-for="(paragraph, index) in story.story_text.split('\n\n')"
|
v-for="(paragraph, index) in storyParagraphs"
|
||||||
:key="index"
|
: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"
|
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 +293,10 @@ onUnmounted(() => {
|
|||||||
@click="loadAudio"
|
@click="loadAudio"
|
||||||
class="mx-auto"
|
class="mx-auto"
|
||||||
>
|
>
|
||||||
<template v-if="audioLoading">加载中...</template>
|
<template v-if="audioLoading">正在准备音频...</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SpeakerWaveIcon class="h-5 w-5" />
|
<SpeakerWaveIcon class="h-5 w-5" />
|
||||||
听故事
|
试听故事
|
||||||
</template>
|
</template>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +331,7 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 rounded-full transition-all duration-100"
|
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}%` }"
|
:style="{ width: `${(audioProgress / audioDuration) * 100 || 0}%` }"
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-sm text-gray-500 mt-1">
|
<div class="flex justify-between text-sm text-gray-500 mt-1">
|
||||||
<span>{{ formatTime(audioProgress) }}</span>
|
<span>{{ formatTime(audioProgress) }}</span>
|
||||||
@@ -301,7 +370,7 @@ onUnmounted(() => {
|
|||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
:show="showDeleteConfirm"
|
:show="showDeleteConfirm"
|
||||||
title="确定删除这个故事吗?"
|
title="确定删除这个故事吗?"
|
||||||
message="删除后将无法恢复"
|
message="删除后将无法恢复。"
|
||||||
confirm-text="确定删除"
|
confirm-text="确定删除"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
@@ -1,37 +1,89 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { api } from '../api/client'
|
||||||
import { useStorybookStore } from '../stores/storybook'
|
import { useStorybookStore } from '../stores/storybook'
|
||||||
import BaseButton from '../components/ui/BaseButton.vue'
|
import BaseButton from '../components/ui/BaseButton.vue'
|
||||||
|
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||||
|
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
HomeIcon,
|
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
HomeIcon,
|
||||||
|
PhotoIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
PhotoIcon
|
|
||||||
} from '@heroicons/vue/24/outline'
|
} 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 router = useRouter()
|
||||||
const store = useStorybookStore()
|
const store = useStorybookStore()
|
||||||
|
|
||||||
const storybook = computed(() => store.currentStorybook)
|
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 totalPages = computed(() => storybook.value?.pages.length || 0)
|
||||||
const isCover = computed(() => currentPageIndex.value === -1)
|
const isCover = computed(() => currentPageIndex.value === -1)
|
||||||
const isLastPage = computed(() => currentPageIndex.value === totalPages.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(() => {
|
const currentPage = computed(() => {
|
||||||
if (!storybook.value || isCover.value) return null
|
if (!storybook.value || isCover.value) return null
|
||||||
return storybook.value.pages[currentPageIndex.value]
|
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() {
|
function goHome() {
|
||||||
store.clearStorybook()
|
store.clearStorybook()
|
||||||
router.push('/')
|
router.push('/my-stories')
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
@@ -46,16 +98,89 @@ function prevPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
async function loadStorybook() {
|
||||||
if (!storybook.value) {
|
loading.value = true
|
||||||
router.push('/')
|
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('/my-stories')
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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">
|
<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" />
|
<HomeIcon class="w-6 h-6" />
|
||||||
@@ -63,39 +188,31 @@ onMounted(() => {
|
|||||||
<div class="text-white font-serif text-lg text-shadow">
|
<div class="text-white font-serif text-lg text-shadow">
|
||||||
{{ storybook.title }}
|
{{ storybook.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10"></div> <!-- 占位 -->
|
<div class="w-10"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 主展示区 -->
|
|
||||||
<div class="h-screen w-full flex items-center justify-center p-4 md:p-8 relative overflow-hidden">
|
<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-[#0D0F1A] z-0">
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
|
||||||
<div class="stars"></div>
|
<div class="stars"></div>
|
||||||
</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 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 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">
|
<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">
|
<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" />
|
<img :src="storybook.cover_url" class="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" />
|
||||||
</template>
|
</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">
|
<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" />
|
<SparklesIcon class="w-20 h-20 mb-4 opacity-50" />
|
||||||
<p class="text-white/60 text-sm">封面正在构思中...</p>
|
<p class="text-white/70 text-sm max-w-xs leading-6">{{ imageMeta.description }}</p>
|
||||||
</div>
|
</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 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">
|
<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>
|
</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="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">
|
<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>
|
<span class="inline-block px-4 py-1 border border-amber-900/30 rounded-full text-sm tracking-widest uppercase">Original Storybook</span>
|
||||||
@@ -104,24 +221,46 @@ onMounted(() => {
|
|||||||
<h1 class="text-4xl md:text-6xl font-serif font-bold mb-6 leading-tight">{{ storybook.title }}</h1>
|
<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">
|
<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.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.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>
|
</div>
|
||||||
|
|
||||||
<BaseButton size="lg" @click="nextPage" class="self-start shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all">
|
<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>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内页模式 -->
|
|
||||||
<div v-else class="w-full h-full flex flex-col md:flex-row animate-fade-in relative">
|
<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">
|
<div class="absolute bottom-4 right-6 text-amber-900/30 font-serif text-xl z-20">
|
||||||
{{ currentPageIndex + 1 }} / {{ totalPages }}
|
{{ currentPageIndex + 1 }} / {{ totalPages }}
|
||||||
</div>
|
</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">
|
<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">
|
<template v-if="currentPage?.image_url">
|
||||||
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
|
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
|
||||||
@@ -131,12 +270,19 @@ onMounted(() => {
|
|||||||
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
|
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
|
||||||
<PhotoIcon class="w-10 h-10 text-amber-300" />
|
<PhotoIcon class="w-10 h-10 text-amber-300" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-amber-900/40 text-sm max-w-xs mx-auto italic">"{{ currentPage?.image_prompt }}"</p>
|
<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>
|
||||||
</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="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">
|
<div class="prose prose-xl prose-amber font-serif text-amber-900 leading-relaxed text-center md:text-left">
|
||||||
<p>{{ currentPage?.text }}</p>
|
<p>{{ currentPage?.text }}</p>
|
||||||
@@ -145,7 +291,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 翻页控制 (悬浮) -->
|
|
||||||
<button
|
<button
|
||||||
v-if="!isCover"
|
v-if="!isCover"
|
||||||
@click="prevPage"
|
@click="prevPage"
|
||||||
@@ -162,7 +307,6 @@ onMounted(() => {
|
|||||||
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
|
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 最后一页的完成按钮 -->
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="isLastPage"
|
v-if="isLastPage"
|
||||||
@click="goHome"
|
@click="goHome"
|
||||||
@@ -170,7 +314,6 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
读完了,再来一本
|
读完了,再来一本
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.admin_auth import admin_guard
|
from app.core.admin_auth import admin_guard
|
||||||
from app.db.admin_models import Provider
|
from app.db.admin_models import Provider
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
|
from app.services.adapters.registry import AdapterRegistry
|
||||||
from app.services.cost_tracker import cost_tracker
|
from app.services.cost_tracker import cost_tracker
|
||||||
|
from app.services.provider_router import DEFAULT_PROVIDERS
|
||||||
from app.services.secret_service import SecretService
|
from app.services.secret_service import SecretService
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(admin_guard)])
|
router = APIRouter(dependencies=[Depends(admin_guard)])
|
||||||
@@ -54,11 +56,6 @@ class ProviderResponse(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
from app.services.adapters.registry import AdapterRegistry
|
|
||||||
from app.services.provider_router import DEFAULT_PROVIDERS
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers/adapters")
|
@router.get("/providers/adapters")
|
||||||
async def list_available_adapters():
|
async def list_available_adapters():
|
||||||
"""获取所有可用的适配器类型 (定义的类)。"""
|
"""获取所有可用的适配器类型 (定义的类)。"""
|
||||||
|
|||||||
@@ -266,13 +266,18 @@ async def get_profile_timeline(
|
|||||||
if not obt_at:
|
if not obt_at:
|
||||||
obt_at = u.updated_at.isoformat()
|
obt_at = u.updated_at.isoformat()
|
||||||
|
|
||||||
events.append(TimelineEvent(
|
events.append(
|
||||||
|
TimelineEvent(
|
||||||
date=obt_at,
|
date=obt_at,
|
||||||
type="achievement",
|
type="achievement",
|
||||||
title=f"获得成就:{ach.get('type')}",
|
title=f"获得成就:{ach.get('type')}",
|
||||||
description=ach.get('description'),
|
description=ach.get("description"),
|
||||||
metadata={"universe_id": u.id, "source_story_id": ach.get("source_story_id")}
|
metadata={
|
||||||
))
|
"universe_id": u.id,
|
||||||
|
"source_story_id": ach.get("source_story_id"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Sort by date desc
|
# Sort by date desc
|
||||||
events.sort(key=lambda x: x.date, reverse=True)
|
events.sort(key=lambda x: x.date, reverse=True)
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ class ReadingEventResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reading-events", response_model=ReadingEventResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"/reading-events",
|
||||||
|
response_model=ReadingEventResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
async def create_reading_event(
|
async def create_reading_event(
|
||||||
payload: ReadingEventCreate,
|
payload: ReadingEventCreate,
|
||||||
user: User = Depends(require_user),
|
user: User = Depends(require_user),
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ class Provider(Base):
|
|||||||
weight: Mapped[int] = mapped_column(Integer, default=1)
|
weight: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
priority: Mapped[int] = mapped_column(Integer, default=0)
|
priority: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) # 存储额外配置(speed, vol, etc)
|
config_json: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
) # 存储额外配置(speed, vol, etc)
|
||||||
config_ref: Mapped[str] = mapped_column(String(100), nullable=True) # 环境变量 key 名称(回退)
|
config_ref: Mapped[str] = mapped_column(String(100), nullable=True) # 环境变量 key 名称(回退)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
"""图像生成适配器。"""# Image adapters
|
"""图像生成适配器。"""
|
||||||
from app.services.adapters.image import cqtai as _image_cqtai_adapter # noqa: F401
|
|
||||||
from app.services.adapters.image import antigravity as _image_antigravity_adapter # noqa: F401
|
from app.services.adapters.image import antigravity as _image_antigravity_adapter # noqa: F401
|
||||||
|
from app.services.adapters.image import cqtai as _image_cqtai_adapter # noqa: F401
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
支持 gemini-3-pro-image 等模型。
|
支持 gemini-3-pro-image 等模型。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
from tenacity import (
|
from tenacity import (
|
||||||
@@ -108,7 +106,7 @@ class AntigravityImageAdapter(BaseAdapter[str]):
|
|||||||
"""检查 Antigravity API 是否可用。"""
|
"""检查 Antigravity API 是否可用。"""
|
||||||
try:
|
try:
|
||||||
# 简单测试连通性
|
# 简单测试连通性
|
||||||
response = await self.client.chat.completions.create(
|
await self.client.chat.completions.create(
|
||||||
model=self.config.model or DEFAULT_MODEL,
|
model=self.config.model or DEFAULT_MODEL,
|
||||||
messages=[{"role": "user", "content": "test"}],
|
messages=[{"role": "user", "content": "test"}],
|
||||||
max_tokens=1,
|
max_tokens=1,
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ async def build_enhanced_memory_context(
|
|||||||
|
|
||||||
# 常驻角色
|
# 常驻角色
|
||||||
if universe.recurring_characters:
|
if universe.recurring_characters:
|
||||||
chars = [f"{c.get('name')} ({c.get('type')})" for c in universe.recurring_characters if isinstance(c, dict)]
|
chars = [
|
||||||
|
f"{c.get('name')} ({c.get('type')})"
|
||||||
|
for c in universe.recurring_characters
|
||||||
|
if isinstance(c, dict)
|
||||||
|
]
|
||||||
context_parts.append(f"已知伙伴:{'、'.join(chars)}")
|
context_parts.append(f"已知伙伴:{'、'.join(chars)}")
|
||||||
|
|
||||||
# 成就
|
# 成就
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ ProviderType = Literal["text", "image", "tts", "storybook"]
|
|||||||
|
|
||||||
class CachedProvider(BaseModel):
|
class CachedProvider(BaseModel):
|
||||||
"""Serializable provider configuration matching DB model fields."""
|
"""Serializable provider configuration matching DB model fields."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
type: str
|
type: str
|
||||||
@@ -49,7 +50,8 @@ async def reload_providers(db: AsyncSession) -> dict[ProviderType, list[CachedPr
|
|||||||
# Convert to Pydantic models
|
# Convert to Pydantic models
|
||||||
cached_list = []
|
cached_list = []
|
||||||
for p in providers:
|
for p in providers:
|
||||||
cached_list.append(CachedProvider(
|
cached_list.append(
|
||||||
|
CachedProvider(
|
||||||
id=p.id,
|
id=p.id,
|
||||||
name=p.name,
|
name=p.name,
|
||||||
type=p.type,
|
type=p.type,
|
||||||
@@ -63,8 +65,9 @@ async def reload_providers(db: AsyncSession) -> dict[ProviderType, list[CachedPr
|
|||||||
priority=p.priority,
|
priority=p.priority,
|
||||||
enabled=p.enabled,
|
enabled=p.enabled,
|
||||||
config_json=p.config_json,
|
config_json=p.config_json,
|
||||||
config_ref=p.config_ref
|
config_ref=p.config_ref,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Group by type
|
# Group by type
|
||||||
grouped: dict[str, list[CachedProvider]] = defaultdict(list)
|
grouped: dict[str, list[CachedProvider]] = defaultdict(list)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""认证相关测试。"""
|
"""认证相关测试。"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.core.security import create_access_token, decode_access_token
|
from app.core.security import create_access_token, decode_access_token
|
||||||
|
|||||||
Reference in New Issue
Block a user