From 16fafe0fe026bde0895744622b52edb0f7ba20b5 Mon Sep 17 00:00:00 2001 From: Yuyan Date: Sat, 18 Apr 2026 14:18:17 +0800 Subject: [PATCH] chore: retire demo technical debt --- README.md | 2 +- admin-frontend/Dockerfile | 4 +- .../src/components/CreateStoryModal.vue | 88 ++++++++--- .../src/components/ui/AnalysisAnimation.vue | 141 ++++++++++++++++++ .../src/views/ChildProfileTimeline.vue | 32 ++-- admin-frontend/src/views/StoryDetail.vue | 74 ++++++++- admin-frontend/src/views/StorybookViewer.vue | 80 ++++++++++ backend/.env.example | 2 +- backend/app/api/memories.py | 19 ++- backend/app/api/profiles.py | 7 +- backend/app/api/push_configs.py | 7 +- backend/app/api/reading_events.py | 7 +- backend/app/api/stories.py | 6 +- backend/app/api/universes.py | 7 +- backend/app/core/config.py | 24 +-- backend/app/services/story_service.py | 4 +- docs/planning/demo-validation-log.md | 10 +- docs/planning/week-2-execution-backlog.md | 3 + frontend/Dockerfile | 4 +- frontend/src/views/ChildProfileTimeline.vue | 32 ++-- frontend/src/views/StoryDetail.vue | 4 +- 21 files changed, 442 insertions(+), 115 deletions(-) create mode 100644 admin-frontend/src/components/ui/AnalysisAnimation.vue diff --git a/README.md b/README.md index acd2eb7..259644c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/admin-frontend/Dockerfile b/admin-frontend/Dockerfile index b5a1329..d222861 100644 --- a/admin-frontend/Dockerfile +++ b/admin-frontend/Dockerfile @@ -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 diff --git a/admin-frontend/src/components/CreateStoryModal.vue b/admin-frontend/src/components/CreateStoryModal.vue index 8f6faaa..07984c3 100644 --- a/admin-frontend/src/components/CreateStoryModal.vue +++ b/admin-frontend/src/components/CreateStoryModal.vue @@ -7,9 +7,10 @@ import { useUserStore } from '../stores/user' import { useStorybookStore } from '../stores/storybook' import { api } from '../api/client' 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 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, @@ -72,12 +73,37 @@ const themes: ThemeOption[] = [ { icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' }, ] -const profileOptions = computed(() => - profiles.value.map(profile => ({ value: profile.id, label: profile.name })), -) -const universeOptions = computed(() => - universes.value.map(universe => ({ value: universe.id, label: universe.name })), -) +const profileOptions = computed(() => + profiles.value.map(profile => ({ value: profile.id, label: profile.name })), +) +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 = { - 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('/api/generations', payload) storybookStore.setStorybook(response) @@ -187,14 +211,21 @@ async function generateStory() { v-if="modelValue" class="fixed inset-0 z-50 flex items-center justify-center p-4" > - -
- - -
+ +
+ + + + + +
音频资源
@@ -273,9 +327,24 @@ onUnmounted(() => {

{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。

+ + {{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }} +
+
+
资源补全策略
+

{{ assetGuidance }}

+
+

{

diff --git a/admin-frontend/src/views/StorybookViewer.vue b/admin-frontend/src/views/StorybookViewer.vue index 8db7563..9a47730 100644 --- a/admin-frontend/src/views/StorybookViewer.vue +++ b/admin-frontend/src/views/StorybookViewer.vue @@ -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( + `/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(

{{ imageMeta.description }}

+ + {{ storybook.image_status === 'failed' ? '重试插图' : '补全插图' }} +
@@ -249,6 +301,24 @@ watch(

{{ storybook.last_error }}

+
+
插图可稍后补全
+

+ 绘本文字已经保存,可以先阅读;补全插图会更新封面和缺失页面。 +

+ + {{ storybook.image_status === 'failed' ? '重试全部插图' : '补全全部插图' }} + +
+ 开始阅读 @@ -273,6 +343,16 @@ watch(

{{ pageImageMessage }}

+ + 补全插图 +

list[tuple[str, int]]: diff --git a/backend/app/services/story_service.py b/backend/app/services/story_service.py index 4514ec2..5c8ba35 100644 --- a/backend/app/services/story_service.py +++ b/backend/app/services/story_service.py @@ -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, limit: int, offset: int, diff --git a/docs/planning/demo-validation-log.md b/docs/planning/demo-validation-log.md index 27ec82e..aeb62cf 100644 --- a/docs/planning/demo-validation-log.md +++ b/docs/planning/demo-validation-log.md @@ -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 恢复的绘本阅读器。 限制: diff --git a/docs/planning/week-2-execution-backlog.md b/docs/planning/week-2-execution-backlog.md index 7928321..e5380f2 100644 --- a/docs/planning/week-2-execution-backlog.md +++ b/docs/planning/week-2-execution-backlog.md @@ -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 | 测试无 warning,Docker build 无 casing warning | P1 | 0.5d | Done | +| W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不再分叉 | P1 | 0.5d | Done | +| W2-15 | Security | 移除管理后台弱默认密码 | 非 debug 管理后台拒绝空/弱密码 | P1 | 0.5d | Done | --- diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b5a1329..d222861 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/src/views/ChildProfileTimeline.vue b/frontend/src/views/ChildProfileTimeline.vue index edc6cd2..a1c6e7d 100644 --- a/frontend/src/views/ChildProfileTimeline.vue +++ b/frontend/src/views/ChildProfileTimeline.vue @@ -67,14 +67,11 @@ 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(`/api/profiles/${profileId}`) - profileName.value = profile.name +async function fetchTimeline() { + loading.value = true + try { + const profile = await api.get(`/api/profiles/${profileId}`) + profileName.value = profile.name const data = await api.get(`/api/profiles/${profileId}/timeline`) events.value = data.events @@ -85,18 +82,13 @@ 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}`) - } else { - router.push(`/story/${event.metadata.story_id}`) - } +function handleEventClick(event: TimelineEvent) { + if (event.type === 'story' && event.metadata?.story_id) { + if (event.metadata.mode === 'storybook') { + router.push(`/storybook/view/${event.metadata.story_id}`) + } else { + router.push(`/story/${event.metadata.story_id}`) + } } } diff --git a/frontend/src/views/StoryDetail.vue b/frontend/src/views/StoryDetail.vue index a2ef2af..8cebdbf 100644 --- a/frontend/src/views/StoryDetail.vue +++ b/frontend/src/views/StoryDetail.vue @@ -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(() => {