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/auth/dev/signin
- 管理端http://localhost:52888
- 管理端http://localhost:52888,默认演示账号来自 `backend/.env`
- 后端健康检查http://localhost:52000/health
- 管理后端健康检查http://localhost:52800/health

View File

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

View File

@@ -10,6 +10,7 @@ import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue'
import BaseTextarea from './ui/BaseTextarea.vue'
import AnalysisAnimation from './ui/AnalysisAnimation.vue'
import {
SparklesIcon,
PencilSquareIcon,
@@ -78,6 +79,31 @@ const profileOptions = computed(() =>
const universeOptions = computed(() =>
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
function close() {
@@ -136,10 +162,8 @@ async function generateStory() {
error.value = ''
try {
const requestedOutputMode =
inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story'
const payload: Record<string, unknown> = {
output_mode: requestedOutputMode,
output_mode: requestedOutputMode.value,
type: inputType.value,
data: inputData.value,
education_theme: educationTheme.value || undefined,
@@ -149,7 +173,7 @@ async function generateStory() {
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.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)
storybookStore.setStorybook(response)
@@ -190,11 +214,18 @@ async function generateStory() {
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close"
@click="!loading && close()"
></div>
<!-- 全屏加载动画 -->
<AnalysisAnimation
v-if="loading"
:title="generationTitle"
:steps="generationSteps"
/>
<!-- 模态框内容 -->
<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">
<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
@click="close"
@@ -345,6 +376,17 @@ async function generateStory() {
</Transition>
<!-- 提交按钮 -->
<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"
size="lg"

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

@@ -70,9 +70,6 @@ function formatDate(isoStr: string) {
async function fetchTimeline() {
loading.value = true
try {
// Ideally we should also fetch profile basic info here or if the timeline endpoint included it
// For now, let's just fetch timeline.
// Wait, let's fetch profile first to get the name
const profile = await api.get<any>(`/api/profiles/${profileId}`)
profileName.value = profile.name
@@ -87,13 +84,8 @@ async function fetchTimeline() {
function handleEventClick(event: TimelineEvent) {
if (event.type === 'story' && event.metadata?.story_id) {
// Check mode
if (event.metadata.mode === 'storybook') {
// 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。
// 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。
// 暂时先只支持跳转到普通故事详情,或者给出提示
// TODO: Viewer support loading by ID
router.push(`/story/${event.metadata.story_id}`)
router.push(`/storybook/view/${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 imageMeta = computed(() => getAssetStatusMeta(story.value?.image_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() {
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() {
if (!audioRef.value) return
@@ -266,6 +310,16 @@ onUnmounted(() => {
<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>
<BaseButton
v-if="canRetryImage"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-4 w-full"
@click="generateImage"
>
{{ story.image_status === 'failed' ? '重试封面' : '补全封面' }}
</BaseButton>
</div>
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">音频资源</div>
@@ -273,9 +327,24 @@ onUnmounted(() => {
<p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</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 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">
<p
v-for="(paragraph, index) in storyParagraphs"
@@ -290,13 +359,14 @@ onUnmounted(() => {
<div v-if="!audioUrl" class="text-center">
<BaseButton
:loading="audioLoading"
@click="loadAudio"
:disabled="isAudioGenerating"
@click="story.audio_status === 'ready' ? loadAudio() : retryAudio()"
class="mx-auto"
>
<template v-if="audioLoading">正在准备音频...</template>
<template v-else>
<SpeakerWaveIcon class="h-5 w-5" />
试听故事
{{ isAudioGenerating ? '音频生成中...' : story.audio_status === 'ready' ? '试听故事' : '生成并试听故事' }}
</template>
</BaseButton>
</div>

View File

@@ -41,6 +41,7 @@ const store = useStorybookStore()
const storybook = computed(() => store.currentStorybook)
const loading = ref(true)
const imageLoading = ref(false)
const error = ref('')
const currentPageIndex = ref(-1)
@@ -50,6 +51,11 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value -
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 canRetryImages = computed(() =>
Boolean(storybook.value?.id)
&& storybook.value?.image_status !== 'ready'
&& storybook.value?.image_status !== 'generating',
)
const currentPage = computed(() => {
if (!storybook.value || isCover.value) return null
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(
() => 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">
<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>
<BaseButton
v-if="canRetryImages"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-5"
@click="retryStorybookImages"
>
{{ storybook.image_status === 'failed' ? '重试插图' : '补全插图' }}
</BaseButton>
</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">
@@ -249,6 +301,24 @@ watch(
<p class="leading-6">{{ storybook.last_error }}</p>
</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">
开始阅读
<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">
{{ pageImageMessage }}
</p>
<BaseButton
v-if="canRetryImages"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-4"
@click="retryStorybookImages"
>
补全插图
</BaseButton>
<p
v-if="storybook.last_error && storybook.image_status === 'failed'"
class="mt-3 text-xs font-medium text-amber-700"

View File

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

View File

@@ -1,7 +1,7 @@
"""Memory management APIs."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_user
@@ -16,6 +16,8 @@ router = APIRouter()
class MemoryItemResponse(BaseModel):
"""Memory item response."""
model_config = ConfigDict(from_attributes=True)
id: str
type: str
value: dict
@@ -24,9 +26,6 @@ class MemoryItemResponse(BaseModel):
created_at: str
last_used_at: str | None
class Config:
from_attributes = True
class MemoryListResponse(BaseModel):
"""Memory list response."""

View File

@@ -4,7 +4,7 @@ from datetime import date
from typing import Literal
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.ext.asyncio import AsyncSession
@@ -42,6 +42,8 @@ class ChildProfileUpdate(BaseModel):
class ChildProfileResponse(BaseModel):
"""Profile response."""
model_config = ConfigDict(from_attributes=True)
id: str
name: str
avatar_url: str | None
@@ -53,9 +55,6 @@ class ChildProfileResponse(BaseModel):
stories_count: int
total_reading_time: int
class Config:
from_attributes = True
class ChildProfileListResponse(BaseModel):
"""Profile list response."""

View File

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

View File

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

View File

@@ -247,8 +247,6 @@ async def generate_storybook_api(
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(
limit: int = 20,

View File

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

View File

@@ -89,7 +89,7 @@ class Settings(BaseSettings):
# Admin console
enable_admin_console: bool = False
admin_username: str = "admin"
admin_password: str = "admin123" # 建议通过环境变量覆盖
admin_password: str = ""
# CORS
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"])
@@ -105,6 +105,12 @@ class Settings(BaseSettings):
missing.append("REDIS_SENTINEL_NODES")
if missing:
raise ValueError(f"Missing required settings: {', '.join(missing)}")
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

View File

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

View File

@@ -8,8 +8,12 @@
- 用户前端 Docker 生产构建
- 管理前端 Docker 生产构建
- 用户端与管理端生成/资产状态体验一致性
- 后端 Docker 镜像构建与服务重启
- 后端 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-backend:dev` 构建通过。
- `ruff check app tests` 通过。
- `pytest -q` 通过71 个测试通过。
- `pytest -q` 通过71 个测试通过Pydantic v2 deprecation warning 已清零
- `SMOKE_AUDIO=1 ./scripts/demo_smoke.sh` 通过。
- 本地用户端可通过 `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 页文本并补全全部插图。
- 故事列表能看到最新生成结果。
- 时间线中的绘本事件可以直接进入按 ID 恢复的绘本阅读器。
限制:

View File

@@ -85,6 +85,9 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时
| 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-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
FROM node:18-alpine as build-stage
FROM node:18-alpine AS build-stage
WORKDIR /app
@@ -10,7 +10,7 @@ COPY . .
RUN npm run build
# Production Stage
FROM nginx:alpine as production-stage
FROM nginx:alpine AS production-stage
# 复制构建产物到 Nginx
COPY --from=build-stage /app/dist /usr/share/nginx/html

View File

@@ -70,9 +70,6 @@ function formatDate(isoStr: string) {
async function fetchTimeline() {
loading.value = true
try {
// Ideally we should also fetch profile basic info here or if the timeline endpoint included it
// For now, let's just fetch timeline.
// Wait, let's fetch profile first to get the name
const profile = await api.get<any>(`/api/profiles/${profileId}`)
profileName.value = profile.name
@@ -87,13 +84,8 @@ async function fetchTimeline() {
function handleEventClick(event: TimelineEvent) {
if (event.type === 'story' && event.metadata?.story_id) {
// Check mode
if (event.metadata.mode === 'storybook') {
// 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。
// 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。
// 暂时先只支持跳转到普通故事详情,或者给出提示
// TODO: Viewer support loading by ID
router.push(`/story/${event.metadata.story_id}`)
router.push(`/storybook/view/${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 !== 'generating',
)
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const assetGuidance = computed(() => {
if (story.value?.generation_status === 'degraded_completed') {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
@@ -358,13 +359,14 @@ onUnmounted(() => {
<div v-if="!audioUrl" class="text-center">
<BaseButton
:loading="audioLoading"
:disabled="isAudioGenerating"
@click="story.audio_status === 'ready' ? loadAudio() : retryAudio()"
class="mx-auto"
>
<template v-if="audioLoading">正在准备音频...</template>
<template v-else>
<SpeakerWaveIcon class="h-5 w-5" />
{{ story.audio_status === 'ready' ? '试听故事' : '生成并试听故事' }}
{{ isAudioGenerating ? '音频生成中...' : story.audio_status === 'ready' ? '试听故事' : '生成并试听故事' }}
</template>
</BaseButton>
</div>