chore: retire demo technical debt
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
141
admin-frontend/src/components/ui/AnalysisAnimation.vue
Normal file
141
admin-frontend/src/components/ui/AnalysisAnimation.vue
Normal 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>
|
||||||
@@ -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}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 恢复的绘本阅读器。
|
||||||
|
|
||||||
限制:
|
限制:
|
||||||
|
|
||||||
|
|||||||
@@ -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 | 测试无 warning,Docker build 无 casing warning | P1 | 0.5d | Done |
|
||||||
|
| W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不再分叉 | P1 | 0.5d | Done |
|
||||||
|
| W2-15 | Security | 移除管理后台弱默认密码 | 非 debug 管理后台拒绝空/弱密码 | P1 | 0.5d | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user