chore: retire demo technical debt

This commit is contained in:
2026-04-18 14:18:17 +08:00
parent 0f260f649c
commit 16fafe0fe0
21 changed files with 442 additions and 115 deletions

View File

@@ -52,7 +52,7 @@ docker compose up -d --build
- 用户端http://localhost:52080 - 用户端http://localhost:52080
- 本地开发登录http://localhost:52080/auth/dev/signin - 本地开发登录http://localhost:52080/auth/dev/signin
- 管理端http://localhost:52888 - 管理端http://localhost:52888,默认演示账号来自 `backend/.env`
- 后端健康检查http://localhost:52000/health - 后端健康检查http://localhost:52000/health
- 管理后端健康检查http://localhost:52800/health - 管理后端健康检查http://localhost:52800/health

View File

@@ -1,5 +1,5 @@
# Build Stage # Build Stage
FROM node:18-alpine as build-stage FROM node:18-alpine AS build-stage
WORKDIR /app WORKDIR /app
@@ -10,7 +10,7 @@ COPY . .
RUN npm run build RUN npm run build
# Production Stage # Production Stage
FROM nginx:alpine as production-stage FROM nginx:alpine AS production-stage
# 复制构建产物到 Nginx # 复制构建产物到 Nginx
COPY --from=build-stage /app/dist /usr/share/nginx/html COPY --from=build-stage /app/dist /usr/share/nginx/html

View File

@@ -7,9 +7,10 @@ import { useUserStore } from '../stores/user'
import { useStorybookStore } from '../stores/storybook' import { useStorybookStore } from '../stores/storybook'
import { api } from '../api/client' import { api } from '../api/client'
import BaseButton from './ui/BaseButton.vue' import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue' import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue' import BaseSelect from './ui/BaseSelect.vue'
import BaseTextarea from './ui/BaseTextarea.vue' import BaseTextarea from './ui/BaseTextarea.vue'
import AnalysisAnimation from './ui/AnalysisAnimation.vue'
import { import {
SparklesIcon, SparklesIcon,
PencilSquareIcon, PencilSquareIcon,
@@ -72,12 +73,37 @@ const themes: ThemeOption[] = [
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' }, { icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
] ]
const profileOptions = computed(() => const profileOptions = computed(() =>
profiles.value.map(profile => ({ value: profile.id, label: profile.name })), profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
) )
const universeOptions = computed(() => const universeOptions = computed(() =>
universes.value.map(universe => ({ value: universe.id, label: universe.name })), universes.value.map(universe => ({ value: universe.id, label: universe.name })),
) )
const requestedOutputMode = computed<'story' | 'storybook'>(() =>
inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story',
)
const generationTitle = computed(() =>
requestedOutputMode.value === 'storybook' ? '绘本排版中...' : '故事编织中...',
)
const generationSteps = computed(() => {
if (requestedOutputMode.value === 'storybook') {
return [
'正在整理主题和成长目标...',
'生成绘本分镜和每页文字...',
'保存绘本主记录,确保刷新也能找回...',
'补全封面和分页插图...',
'马上进入可翻页阅读模式。',
]
}
return [
'正在整理孩子档案和故事主题...',
'生成可先阅读的故事正文...',
'保存故事主记录,避免结果丢失...',
'补全封面图,失败也可稍后重试...',
'马上进入故事详情页。',
]
})
// Methods // Methods
function close() { function close() {
@@ -136,10 +162,8 @@ async function generateStory() {
error.value = '' error.value = ''
try { try {
const requestedOutputMode =
inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story'
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
output_mode: requestedOutputMode, output_mode: requestedOutputMode.value,
type: inputType.value, type: inputType.value,
data: inputData.value, data: inputData.value,
education_theme: educationTheme.value || undefined, education_theme: educationTheme.value || undefined,
@@ -149,7 +173,7 @@ async function generateStory() {
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
if (requestedOutputMode === 'storybook') { if (requestedOutputMode.value === 'storybook') {
const response = await api.post<any>('/api/generations', payload) const response = await api.post<any>('/api/generations', payload)
storybookStore.setStorybook(response) storybookStore.setStorybook(response)
@@ -187,14 +211,21 @@ async function generateStory() {
v-if="modelValue" v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
> >
<!-- 遮罩层 --> <!-- 遮罩层 -->
<div <div
class="absolute inset-0 bg-black/60 backdrop-blur-sm" class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close" @click="!loading && close()"
></div> ></div>
<!-- 模态框内容 --> <!-- 全屏加载动画 -->
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8"> <AnalysisAnimation
v-if="loading"
:title="generationTitle"
:steps="generationSteps"
/>
<!-- 模态框内容 -->
<div v-else class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8">
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<button <button
@click="close" @click="close"
@@ -344,8 +375,19 @@ async function generateStory() {
</div> </div>
</Transition> </Transition>
<!-- 提交按钮 --> <!-- 提交按钮 -->
<BaseButton <div class="mb-4 rounded-lg border border-amber-400/20 bg-amber-300/10 px-4 py-3 text-sm text-amber-100 leading-6">
<div class="font-semibold mb-1">
{{ requestedOutputMode === 'storybook' ? '绘本会先保存,再补全插图' : '故事会先可读,再补全封面' }}
</div>
<p>
{{ requestedOutputMode === 'storybook'
? '即使部分插图暂时失败,绘本文字也会保留在故事库,稍后可以继续补全。'
: '封面或语音失败不会影响正文阅读,结果页会给出状态和重试入口。' }}
</p>
</div>
<BaseButton
class="w-full" class="w-full"
size="lg" size="lg"
:loading="loading" :loading="loading"

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
title?: string
steps?: string[]
}>(), {
title: '梦境编织中...',
})
const defaultSteps = [
'正在接收梦境信号...',
'编织故事脉络...',
'绘制精美插画 (需要一点点魔法时间)...',
'撒上一些星光粉...',
'即将完成独一无二的绘本!',
]
const currentStepIndex = ref(0)
const steps = computed(() => props.steps?.length ? props.steps : defaultSteps)
let stepInterval: number | undefined
onMounted(() => {
stepInterval = window.setInterval(() => {
if (currentStepIndex.value < steps.value.length - 1) {
currentStepIndex.value++
}
}, 2500)
})
onUnmounted(() => {
if (stepInterval) clearInterval(stepInterval)
})
</script>
<template>
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#1C2035] overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div v-for="i in 20" :key="i"
class="absolute rounded-full bg-white animate-twinkle"
:style="{
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
width: `${Math.random() * 3 + 1}px`,
height: `${Math.random() * 3 + 1}px`,
animationDelay: `${Math.random() * 3}s`,
opacity: Math.random() * 0.7 + 0.3
}"
></div>
</div>
<div class="relative w-64 h-64 mb-12 flex items-center justify-center">
<div class="absolute inset-0 border-4 border-amber-500/20 rounded-full animate-spin-slow"></div>
<div class="absolute inset-2 border-2 border-amber-400/30 rounded-full animate-spin-reverse-slower"></div>
<div class="relative z-10 w-32 h-32 bg-gradient-to-br from-amber-300 to-orange-500 rounded-full shadow-[0_0_60px_rgba(245,158,11,0.5)] animate-pulse-glow flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-white animate-bounce-gentle" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<div class="absolute inset-0 animate-spin-slow">
<div class="absolute top-0 left-1/2 w-3 h-3 bg-amber-200 rounded-full shadow-lg blur-[1px]"></div>
<div class="absolute bottom-10 right-10 w-2 h-2 bg-purple-300 rounded-full shadow-lg blur-[1px]"></div>
</div>
</div>
<div class="z-10 text-center space-y-4">
<h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x">
{{ props.title }}
</h3>
<Transition mode="out-in" name="fade-slide">
<p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8">
{{ steps[currentStepIndex] }}
</p>
</Transition>
</div>
</div>
</template>
<style scoped>
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-reverse-slower {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); box-shadow: 0 0 40px rgba(245,158,11,0.4); }
50% { transform: scale(1.05); box-shadow: 0 0 70px rgba(245,158,11,0.7); }
}
@keyframes bounce-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes twinkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 0.8; transform: scale(1.2); }
}
.animate-spin-slow {
animation: spin-slow 12s linear infinite;
}
.animate-spin-reverse-slower {
animation: spin-reverse-slower 20s linear infinite;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.animate-bounce-gentle {
animation: bounce-gentle 3s ease-in-out infinite;
}
.animate-twinkle {
animation: twinkle 4s ease-in-out infinite;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.5s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -67,14 +67,11 @@ function formatDate(isoStr: string) {
}) })
} }
async function fetchTimeline() { async function fetchTimeline() {
loading.value = true loading.value = true
try { try {
// Ideally we should also fetch profile basic info here or if the timeline endpoint included it const profile = await api.get<any>(`/api/profiles/${profileId}`)
// For now, let's just fetch timeline. profileName.value = profile.name
// Wait, let's fetch profile first to get the name
const profile = await api.get<any>(`/api/profiles/${profileId}`)
profileName.value = profile.name
const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`) const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`)
events.value = data.events events.value = data.events
@@ -85,18 +82,13 @@ async function fetchTimeline() {
} }
} }
function handleEventClick(event: TimelineEvent) { function handleEventClick(event: TimelineEvent) {
if (event.type === 'story' && event.metadata?.story_id) { if (event.type === 'story' && event.metadata?.story_id) {
// Check mode if (event.metadata.mode === 'storybook') {
if (event.metadata.mode === 'storybook') { router.push(`/storybook/view/${event.metadata.story_id}`)
// 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。 } else {
// 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。 router.push(`/story/${event.metadata.story_id}`)
// 暂时先只支持跳转到普通故事详情,或者给出提示 }
// TODO: Viewer support loading by ID
router.push(`/story/${event.metadata.story_id}`)
} else {
router.push(`/story/${event.metadata.story_id}`)
}
} }
} }

View File

@@ -53,6 +53,28 @@ const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ??
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status)) const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status)) const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
const canRetryImage = computed(() =>
Boolean(story.value?.cover_prompt)
&& story.value?.image_status !== 'ready'
&& story.value?.image_status !== 'generating',
)
const canRetryAudio = computed(() =>
Boolean(story.value?.story_text)
&& story.value?.audio_status !== 'ready'
&& story.value?.audio_status !== 'generating',
)
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const assetGuidance = computed(() => {
if (story.value?.generation_status === 'degraded_completed') {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
}
if (story.value?.generation_status === 'assets_generating') {
return '资源正在处理中,可以稍后刷新查看最新状态。'
}
return '封面和音频都是可补全资产,首次生成后会保存状态并复用结果。'
})
async function refreshStorySnapshot() { async function refreshStorySnapshot() {
const data = await api.get<Story>(`/api/stories/${route.params.id}`) const data = await api.get<Story>(`/api/stories/${route.params.id}`)
@@ -122,6 +144,28 @@ async function loadAudio() {
} }
} }
async function retryAudio() {
if (!story.value) return
audioLoading.value = true
error.value = ''
try {
story.value = await api.post<Story>(`/api/generations/${story.value.id}/retry-assets`, {
assets: ['audio'],
})
if (story.value.audio_status === 'ready') {
audioUrl.value = null
await loadAudio()
}
} catch (e) {
error.value = e instanceof Error ? e.message : '音频生成失败'
await refreshStorySnapshot().catch(() => undefined)
} finally {
audioLoading.value = false
}
}
function togglePlay() { function togglePlay() {
if (!audioRef.value) return if (!audioRef.value) return
@@ -266,6 +310,16 @@ onUnmounted(() => {
<div class="text-sm text-gray-500 mb-2">封面资源</div> <div class="text-sm text-gray-500 mb-2">封面资源</div>
<div class="font-semibold text-gray-800 mb-2">{{ imageMeta.label }}</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> <p class="text-sm text-gray-500 leading-6">{{ imageMeta.description }}</p>
<BaseButton
v-if="canRetryImage"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-4 w-full"
@click="generateImage"
>
{{ story.image_status === 'failed' ? '重试封面' : '补全封面' }}
</BaseButton>
</div> </div>
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5"> <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="text-sm text-gray-500 mb-2">音频资源</div>
@@ -273,9 +327,24 @@ onUnmounted(() => {
<p class="text-sm text-gray-500 leading-6"> <p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果 {{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</p> </p>
<BaseButton
v-if="canRetryAudio"
size="sm"
variant="secondary"
:loading="audioLoading"
class="mt-4 w-full"
@click="retryAudio"
>
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
</BaseButton>
</div> </div>
</div> </div>
<div class="mb-10 rounded-lg border border-emerald-100 bg-emerald-50/80 p-4 text-sm text-emerald-800 leading-6">
<div class="font-semibold mb-1">资源补全策略</div>
<p>{{ assetGuidance }}</p>
</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 storyParagraphs" v-for="(paragraph, index) in storyParagraphs"
@@ -290,13 +359,14 @@ onUnmounted(() => {
<div v-if="!audioUrl" class="text-center"> <div v-if="!audioUrl" class="text-center">
<BaseButton <BaseButton
:loading="audioLoading" :loading="audioLoading"
@click="loadAudio" :disabled="isAudioGenerating"
@click="story.audio_status === 'ready' ? loadAudio() : retryAudio()"
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" />
试听故事 {{ isAudioGenerating ? '音频生成中...' : story.audio_status === 'ready' ? '试听故事' : '生成并试听故事' }}
</template> </template>
</BaseButton> </BaseButton>
</div> </div>

View File

@@ -41,6 +41,7 @@ const store = useStorybookStore()
const storybook = computed(() => store.currentStorybook) const storybook = computed(() => store.currentStorybook)
const loading = ref(true) const loading = ref(true)
const imageLoading = ref(false)
const error = ref('') const error = ref('')
const currentPageIndex = ref(-1) const currentPageIndex = ref(-1)
@@ -50,6 +51,11 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value -
const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status)) const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status)) const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status))
const canRetryImages = computed(() =>
Boolean(storybook.value?.id)
&& storybook.value?.image_status !== 'ready'
&& storybook.value?.image_status !== 'generating',
)
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]
@@ -153,6 +159,42 @@ async function loadStorybook() {
} }
} }
async function retryStorybookImages() {
if (!storybook.value?.id) return
imageLoading.value = true
error.value = ''
try {
const detail = await api.post<StoryDetailResponse>(
`/api/generations/${storybook.value.id}/retry-assets`,
{ assets: ['image'] },
)
store.setStorybook({
id: detail.id,
title: detail.title,
main_character: storybook.value.main_character || '故事主角',
art_style: storybook.value.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 : '插图补全失败'
await loadStorybook().catch(() => undefined)
} finally {
imageLoading.value = false
}
}
watch( watch(
() => route.params.id, () => route.params.id,
() => { () => {
@@ -206,6 +248,16 @@ watch(
<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/70 text-sm max-w-xs leading-6">{{ imageMeta.description }}</p> <p class="text-white/70 text-sm max-w-xs leading-6">{{ imageMeta.description }}</p>
<BaseButton
v-if="canRetryImages"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-5"
@click="retryStorybookImages"
>
{{ storybook.image_status === 'failed' ? '重试插图' : '补全插图' }}
</BaseButton>
</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">
@@ -249,6 +301,24 @@ watch(
<p class="leading-6">{{ storybook.last_error }}</p> <p class="leading-6">{{ storybook.last_error }}</p>
</div> </div>
<div
v-if="canRetryImages"
class="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
>
<div class="font-semibold mb-1">插图可稍后补全</div>
<p class="leading-6 mb-3">
绘本文字已经保存可以先阅读补全插图会更新封面和缺失页面
</p>
<BaseButton
size="sm"
variant="secondary"
:loading="imageLoading"
@click="retryStorybookImages"
>
{{ storybook.image_status === 'failed' ? '重试全部插图' : '补全全部插图' }}
</BaseButton>
</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" />
@@ -273,6 +343,16 @@ watch(
<p class="text-amber-900/50 text-sm max-w-xs mx-auto italic leading-6"> <p class="text-amber-900/50 text-sm max-w-xs mx-auto italic leading-6">
{{ pageImageMessage }} {{ pageImageMessage }}
</p> </p>
<BaseButton
v-if="canRetryImages"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-4"
@click="retryStorybookImages"
>
补全插图
</BaseButton>
<p <p
v-if="storybook.last_error && storybook.image_status === 'failed'" v-if="storybook.last_error && storybook.image_status === 'failed'"
class="mt-3 text-xs font-medium text-amber-700" class="mt-3 text-xs font-medium text-amber-700"

View File

@@ -99,7 +99,7 @@ ENABLE_ADMIN_CONSOLE=true
# 管理员 Basic Auth 账号 # 管理员 Basic Auth 账号
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin ADMIN_PASSWORD=local-demo-admin
# ---------------------------------------------- # ----------------------------------------------

View File

@@ -1,7 +1,7 @@
"""Memory management APIs.""" """Memory management APIs."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_user from app.core.deps import require_user
@@ -13,10 +13,12 @@ from app.services.memory_service import MemoryType
router = APIRouter() router = APIRouter()
class MemoryItemResponse(BaseModel): class MemoryItemResponse(BaseModel):
"""Memory item response.""" """Memory item response."""
id: str model_config = ConfigDict(from_attributes=True)
id: str
type: str type: str
value: dict value: dict
base_weight: float base_weight: float
@@ -24,11 +26,8 @@ class MemoryItemResponse(BaseModel):
created_at: str created_at: str
last_used_at: str | None last_used_at: str | None
class Config:
from_attributes = True class MemoryListResponse(BaseModel):
class MemoryListResponse(BaseModel):
"""Memory list response.""" """Memory list response."""
memories: list[MemoryItemResponse] memories: list[MemoryItemResponse]

View File

@@ -4,7 +4,7 @@ from datetime import date
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -42,6 +42,8 @@ class ChildProfileUpdate(BaseModel):
class ChildProfileResponse(BaseModel): class ChildProfileResponse(BaseModel):
"""Profile response.""" """Profile response."""
model_config = ConfigDict(from_attributes=True)
id: str id: str
name: str name: str
avatar_url: str | None avatar_url: str | None
@@ -53,9 +55,6 @@ class ChildProfileResponse(BaseModel):
stories_count: int stories_count: int
total_reading_time: int total_reading_time: int
class Config:
from_attributes = True
class ChildProfileListResponse(BaseModel): class ChildProfileListResponse(BaseModel):
"""Profile list response.""" """Profile list response."""

View File

@@ -3,7 +3,7 @@
from datetime import time from datetime import time
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,15 +26,14 @@ class PushConfigUpsert(BaseModel):
class PushConfigResponse(BaseModel): class PushConfigResponse(BaseModel):
"""Push config response.""" """Push config response."""
model_config = ConfigDict(from_attributes=True)
id: str id: str
child_profile_id: str child_profile_id: str
push_time: time | None push_time: time | None
push_days: list[int] push_days: list[int]
enabled: bool enabled: bool
class Config:
from_attributes = True
class PushConfigListResponse(BaseModel): class PushConfigListResponse(BaseModel):
"""Push config list response.""" """Push config list response."""

View File

@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,6 +34,8 @@ class ReadingEventCreate(BaseModel):
class ReadingEventResponse(BaseModel): class ReadingEventResponse(BaseModel):
"""Reading event response.""" """Reading event response."""
model_config = ConfigDict(from_attributes=True)
id: int id: int
child_profile_id: str child_profile_id: str
story_id: int | None story_id: int | None
@@ -41,9 +43,6 @@ class ReadingEventResponse(BaseModel):
reading_time: int reading_time: int
created_at: datetime created_at: datetime
class Config:
from_attributes = True
@router.post( @router.post(
"/reading-events", "/reading-events",

View File

@@ -247,10 +247,8 @@ async def generate_storybook_api(
return await story_service.generate_storybook_service(request, user.id, db) return await story_service.generate_storybook_service(request, user.id, db)
# ==================== Missing Endpoints (Issue #5) ==================== @router.get("/stories", response_model=list[StoryListItem])
async def list_stories(
@router.get("/stories", response_model=list[StoryListItem])
async def list_stories(
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
user: User = Depends(require_user), user: User = Depends(require_user),

View File

@@ -3,7 +3,7 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -42,6 +42,8 @@ class AchievementCreate(BaseModel):
class StoryUniverseResponse(BaseModel): class StoryUniverseResponse(BaseModel):
"""Universe response.""" """Universe response."""
model_config = ConfigDict(from_attributes=True)
id: str id: str
child_profile_id: str child_profile_id: str
name: str name: str
@@ -50,9 +52,6 @@ class StoryUniverseResponse(BaseModel):
world_settings: dict[str, Any] world_settings: dict[str, Any]
achievements: list[dict[str, Any]] achievements: list[dict[str, Any]]
class Config:
from_attributes = True
class StoryUniverseListResponse(BaseModel): class StoryUniverseListResponse(BaseModel):
"""Universe list response.""" """Universe list response."""

View File

@@ -86,10 +86,10 @@ class Settings(BaseSettings):
description="Socket timeout in seconds for Sentinel clients", description="Socket timeout in seconds for Sentinel clients",
) )
# Admin console # Admin console
enable_admin_console: bool = False enable_admin_console: bool = False
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "admin123" # 建议通过环境变量覆盖 admin_password: str = ""
# CORS # CORS
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"]) cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"])
@@ -101,11 +101,17 @@ class Settings(BaseSettings):
missing.append("SECRET_KEY") missing.append("SECRET_KEY")
if not self.database_url: if not self.database_url:
missing.append("DATABASE_URL") missing.append("DATABASE_URL")
if self.redis_sentinel_enabled and not self.redis_sentinel_nodes.strip(): if self.redis_sentinel_enabled and not self.redis_sentinel_nodes.strip():
missing.append("REDIS_SENTINEL_NODES") missing.append("REDIS_SENTINEL_NODES")
if missing: if missing:
raise ValueError(f"Missing required settings: {', '.join(missing)}") raise ValueError(f"Missing required settings: {', '.join(missing)}")
return self if self.enable_admin_console:
weak_admin_passwords = {"", "admin", "admin123", "password", "change-me"}
if not self.debug and self.admin_password in weak_admin_passwords:
raise ValueError(
"ADMIN_PASSWORD must be set to a strong value when admin console is enabled"
)
return self
@property @property
def redis_sentinel_hosts(self) -> list[tuple[str, int]]: def redis_sentinel_hosts(self) -> list[tuple[str, int]]:

View File

@@ -794,9 +794,7 @@ async def generate_generation_service(
) )
# ==================== Missing Endpoints Logic (for Issue #5) ==================== async def list_stories(
async def list_stories(
user_id: str, user_id: str,
limit: int, limit: int,
offset: int, offset: int,

View File

@@ -8,8 +8,12 @@
- 用户前端 Docker 生产构建 - 用户前端 Docker 生产构建
- 管理前端 Docker 生产构建 - 管理前端 Docker 生产构建
- 用户端与管理端生成/资产状态体验一致性
- 后端 Docker 镜像构建与服务重启 - 后端 Docker 镜像构建与服务重启
- 后端 lint 与测试 - 后端 lint 与测试
- Pydantic v2 兼容性 warning 清理
- Dockerfile build warning 清理
- 管理后台弱默认密码防护
- 后端统一生成接口 - 后端统一生成接口
- 故事封面资产补全 - 故事封面资产补全
- 故事音频资产补全 - 故事音频资产补全
@@ -38,9 +42,12 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- Docker 管理前端镜像 `dreamweaver-admin-frontend:dev` 构建通过。 - Docker 管理前端镜像 `dreamweaver-admin-frontend:dev` 构建通过。
- Docker 后端镜像 `dreamweaver-backend:dev` 构建通过。 - Docker 后端镜像 `dreamweaver-backend:dev` 构建通过。
- `ruff check app tests` 通过。 - `ruff check app tests` 通过。
- `pytest -q` 通过71 个测试通过。 - `pytest -q` 通过71 个测试通过Pydantic v2 deprecation warning 已清零
- `SMOKE_AUDIO=1 ./scripts/demo_smoke.sh` 通过。 - `SMOKE_AUDIO=1 ./scripts/demo_smoke.sh` 通过。
- 本地用户端可通过 `http://localhost:52080` 访问。 - 本地用户端可通过 `http://localhost:52080` 访问。
- 本地管理端可通过 `http://localhost:52888` 访问。
- 技术债扫描未发现 `class Config``TODO``FIXME`、旧 Issue 注释或 Dockerfile `FROM ... as`
- 后端不再内置 `admin123` 管理密码;非 debug 环境开启管理后台时会拒绝空/弱密码。
已确认的演示能力: 已确认的演示能力:
@@ -48,6 +55,7 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- 封面和音频可以作为资产单独重试。 - 封面和音频可以作为资产单独重试。
- 绘本可以生成 6 页文本并补全全部插图。 - 绘本可以生成 6 页文本并补全全部插图。
- 故事列表能看到最新生成结果。 - 故事列表能看到最新生成结果。
- 时间线中的绘本事件可以直接进入按 ID 恢复的绘本阅读器。
限制: 限制:

View File

@@ -85,6 +85,9 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时
| W2-10 | QA | 补前端关键路径构建与 smoke 验证记录 | Docker build + smoke 输出 | P1 | 0.5d | Done | | W2-10 | QA | 补前端关键路径构建与 smoke 验证记录 | Docker build + smoke 输出 | P1 | 0.5d | Done |
| W2-11 | Docs | 输出 Week 1 Sprint Review | `docs/planning/week-1-sprint-review.md` | P1 | 0.5d | Done | | W2-11 | Docs | 输出 Week 1 Sprint Review | `docs/planning/week-1-sprint-review.md` | P1 | 0.5d | Done |
| W2-12 | Docs | 更新 README 的演示前检查流程 | README 本地演示说明 | P1 | 0.5d | Done | | W2-12 | Docs | 更新 README 的演示前检查流程 | README 本地演示说明 | P1 | 0.5d | Done |
| W2-13 | Tech Debt | 清理 Pydantic v2 warning、Dockerfile warning 和旧 TODO | 测试无 warningDocker build 无 casing warning | P1 | 0.5d | Done |
| W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不再分叉 | P1 | 0.5d | Done |
| W2-15 | Security | 移除管理后台弱默认密码 | 非 debug 管理后台拒绝空/弱密码 | P1 | 0.5d | Done |
--- ---

View File

@@ -1,5 +1,5 @@
# Build Stage # Build Stage
FROM node:18-alpine as build-stage FROM node:18-alpine AS build-stage
WORKDIR /app WORKDIR /app
@@ -10,7 +10,7 @@ COPY . .
RUN npm run build RUN npm run build
# Production Stage # Production Stage
FROM nginx:alpine as production-stage FROM nginx:alpine AS production-stage
# 复制构建产物到 Nginx # 复制构建产物到 Nginx
COPY --from=build-stage /app/dist /usr/share/nginx/html COPY --from=build-stage /app/dist /usr/share/nginx/html

View File

@@ -67,14 +67,11 @@ function formatDate(isoStr: string) {
}) })
} }
async function fetchTimeline() { async function fetchTimeline() {
loading.value = true loading.value = true
try { try {
// Ideally we should also fetch profile basic info here or if the timeline endpoint included it const profile = await api.get<any>(`/api/profiles/${profileId}`)
// For now, let's just fetch timeline. profileName.value = profile.name
// Wait, let's fetch profile first to get the name
const profile = await api.get<any>(`/api/profiles/${profileId}`)
profileName.value = profile.name
const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`) const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`)
events.value = data.events events.value = data.events
@@ -85,18 +82,13 @@ async function fetchTimeline() {
} }
} }
function handleEventClick(event: TimelineEvent) { function handleEventClick(event: TimelineEvent) {
if (event.type === 'story' && event.metadata?.story_id) { if (event.type === 'story' && event.metadata?.story_id) {
// Check mode if (event.metadata.mode === 'storybook') {
if (event.metadata.mode === 'storybook') { router.push(`/storybook/view/${event.metadata.story_id}`)
// 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。 } else {
// 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。 router.push(`/story/${event.metadata.story_id}`)
// 暂时先只支持跳转到普通故事详情,或者给出提示 }
// TODO: Viewer support loading by ID
router.push(`/story/${event.metadata.story_id}`)
} else {
router.push(`/story/${event.metadata.story_id}`)
}
} }
} }

View File

@@ -63,6 +63,7 @@ const canRetryAudio = computed(() =>
&& story.value?.audio_status !== 'ready' && story.value?.audio_status !== 'ready'
&& story.value?.audio_status !== 'generating', && story.value?.audio_status !== 'generating',
) )
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const assetGuidance = computed(() => { const assetGuidance = computed(() => {
if (story.value?.generation_status === 'degraded_completed') { if (story.value?.generation_status === 'degraded_completed') {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。' return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
@@ -358,13 +359,14 @@ onUnmounted(() => {
<div v-if="!audioUrl" class="text-center"> <div v-if="!audioUrl" class="text-center">
<BaseButton <BaseButton
:loading="audioLoading" :loading="audioLoading"
:disabled="isAudioGenerating"
@click="story.audio_status === 'ready' ? loadAudio() : retryAudio()" @click="story.audio_status === 'ready' ? loadAudio() : retryAudio()"
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" />
{{ story.audio_status === 'ready' ? '试听故事' : '生成并试听故事' }} {{ isAudioGenerating ? '音频生成中...' : story.audio_status === 'ready' ? '试听故事' : '生成并试听故事' }}
</template> </template>
</BaseButton> </BaseButton>
</div> </div>