Initial commit: clean project structure

- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+)
- Frontend: Vue 3 + TypeScript + Pinia + Tailwind
- Admin Frontend: separate Vue 3 app for management
- Docker Compose: 9 services orchestration
- Specs: design prototypes, memory system PRD, product roadmap

Cleanup performed:
- Removed temporary debug scripts from backend root
- Removed deprecated admin_app.py (embedded UI)
- Removed duplicate docs from admin-frontend
- Updated .gitignore for Vite cache and egg-info
This commit is contained in:
zhangtuo
2026-01-20 18:20:03 +08:00
commit e9d7f8832a
241 changed files with 33070 additions and 0 deletions

View File

@@ -0,0 +1,353 @@
<template>
<main class="max-w-7xl mx-auto px-4 py-8">
<BaseCard v-if="!isLoggedIn" class="max-w-md mx-auto mt-20" padding="lg">
<h1 class="text-3xl font-bold gradient-text mb-4 text-center">DreamWeaver 控制台</h1>
<p class="text-sm text-gray-500 mb-8 text-center">请登录以管理 AI 计算引擎与策略</p>
<form @submit.prevent="login" class="space-y-6">
<BaseInput v-model="loginForm.username" label="管理员账号" required placeholder="admin" />
<BaseInput v-model="loginForm.password" label="密钥密码" type="password" required />
<div class="flex items-center justify-between mt-6">
<span v-if="loginError" class="text-sm text-red-500 font-medium">{{ loginError }}</span>
<BaseButton type="submit" class="w-full">进入控制台</BaseButton>
</div>
</form>
</BaseCard>
<div v-else class="space-y-8">
<!-- Header -->
<header class="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div>
<h1 class="text-3xl font-bold gradient-text">引擎调度中心</h1>
<p class="text-sm text-gray-500 mt-1">Provider Orchestration & Strategy</p>
</div>
<div class="flex items-center gap-3">
<div class="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
系统运行中
</div>
<BaseButton variant="ghost" @click="logout" size="sm">退出</BaseButton>
</div>
</header>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Left: Status & Defaults -->
<div class="lg:col-span-1 space-y-6">
<BaseCard padding="md" title="出厂默认策略 (.env)">
<div class="space-y-4">
<div v-for="(providers, type) in defaults" :key="type" class="p-3 bg-gray-50 rounded-lg border border-gray-100">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ type }}</span>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="p in providers" :key="p" class="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 font-mono">
{{ p }}
</span>
</div>
</div>
<p class="text-xs text-gray-400 mt-2 px-1">
* 当数据库中未配置或全部禁用时系统将回退到上述出厂设置
</p>
</div>
</BaseCard>
<BaseCard padding="md" title="可用驱动 (Adapters)">
<div class="flex flex-wrap gap-2">
<span v-for="adapter in availableAdapters" :key="adapter"
class="px-2 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-100">
{{ adapter.split(':')[1] }} <span class="opacity-50 text-[10px]">({{ adapter.split(':')[0] }})</span>
</span>
</div>
</BaseCard>
</div>
<!-- Right: Active Manager -->
<div class="lg:col-span-3 space-y-6">
<!-- Tabs -->
<div class="flex space-x-1 bg-gray-100 p-1 rounded-xl w-fit">
<button
v-for="tab in ['text', 'image', 'tts', 'storybook']"
:key="tab"
@click="activeTab = tab"
class="px-6 py-2 rounded-lg text-sm font-medium transition-all duration-200"
:class="activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
>
{{ tab.toUpperCase() }}
</button>
</div>
<!-- Provider Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Add New Card -->
<button @click="openCreateModal" class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-200 rounded-2xl hover:border-indigo-300 hover:bg-indigo-50 transition-all group min-h-[200px]">
<div class="w-12 h-12 rounded-full bg-white shadow-sm flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
<svg class="w-6 h-6 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
</div>
<span class="text-sm font-medium text-gray-600 group-hover:text-indigo-600">添加新的 {{ activeTab }} 引擎</span>
</button>
<!-- Existing Cards -->
<div v-for="p in filteredProviders" :key="p.id"
class="relative p-6 bg-white rounded-2xl border transition-all duration-200 group hover:shadow-lg"
:class="p.enabled ? 'border-gray-200' : 'border-gray-100 opacity-75 bg-gray-50'"
>
<!-- Enabled Toggle -->
<div class="absolute top-4 right-4 z-10">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-bold text-lg text-gray-900">{{ p.name }}</h3>
<div class="flex items-center gap-2 mt-1">
<span class="px-2 py-0.5 rounded text-xs font-mono bg-gray-100 text-gray-600 border border-gray-200">
{{ p.adapter }}
</span>
<span v-if="p.model" class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 border border-blue-100 truncate max-w-[120px]">
{{ p.model }}
</span>
</div>
</div>
</div>
<div class="space-y-2 text-sm text-gray-500 mb-6">
<div class="flex justify-between">
<span>Priority:</span>
<span class="font-medium text-gray-700">{{ p.priority }}</span>
</div>
<div class="flex justify-between">
<span>API Key:</span>
<span :class="p.has_api_key ? 'text-green-600' : 'text-yellow-600'">
{{ p.has_api_key ? '● Configured' : '○ Not Set (Env?)' }}
</span>
</div>
</div>
<div class="flex items-center gap-2 pt-4 border-t border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
<BaseButton size="sm" variant="secondary" class="flex-1" @click="edit(p)">配置</BaseButton>
<BaseButton size="sm" variant="ghost" class="text-red-500 hover:bg-red-50" @click="remove(p)">删除</BaseButton>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit/Create Modal -->
<div v-if="editing" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="reset">
<BaseCard class="w-full max-w-2xl max-h-[90vh] overflow-y-auto" padding="lg" @click.stop>
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold gradient-text">{{ form.id ? '编辑引擎配置' : '添加新引擎' }}</h2>
<button @click="reset" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<BaseInput v-model="form.name" label="名称 (显示名)" placeholder="如: Pro GPT-4" required />
<BaseSelect
v-model="form.adapter"
label="驱动程序 (Adapter)"
:options="adapterOptions"
required
description="选择底层的 API 驱动协议"
/>
<BaseInput v-model="form.model" label="模型名称 (Model)" placeholder="如: gpt-4o, minimax-v2" description="具体调用的模型ID" />
<BaseInput v-model.number="form.priority" label="优先级 (0-100)" type="number" description="数字越大越优先" />
<div class="md:col-span-2 p-4 bg-gray-50 rounded-xl border border-gray-100 space-y-4">
<h3 class="text-sm font-bold text-gray-700">密钥与连接</h3>
<BaseInput v-model="form.api_key" label="API Key" type="password" placeholder="留空则使用 .env 配置" :required="!form.id && !form.config_ref" />
<BaseInput v-model="form.api_base" label="API Endpoint / Group ID" placeholder="https://... 或 Group ID" />
<BaseInput v-model="form.config_ref" label="Fallback Env Var" placeholder="如: OPENAI_API_KEY (高级)" />
</div>
<div class="md:col-span-2 flex justify-end gap-3 pt-4 border-t border-gray-100">
<BaseButton variant="secondary" type="button" @click="reset">取消</BaseButton>
<BaseButton type="submit">{{ form.id ? '保存变更' : '立即创建' }}</BaseButton>
</div>
</form>
</BaseCard>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
import BaseSelect from '../components/ui/BaseSelect.vue'
// Types
type Provider = {
id: string
name: string
type: string
adapter: string
model?: string
api_base?: string
has_api_key?: boolean
priority: number
enabled: boolean
config_ref?: string
weight?: number
timeout_ms?: number
max_retries?: number
}
// State
const loginForm = ref({ username: '', password: '' })
const loginError = ref('')
const isLoggedIn = computed(() => !!sessionStorage.getItem('admin_auth'))
const activeTab = ref('text')
const providers = ref<Provider[]>([])
const defaults = ref<Record<string, string[]>>({})
const availableAdapters = ref<string[]>([])
const editing = ref(false)
const form = ref<Partial<Provider> & { api_key?: string }>({
type: 'text',
priority: 10,
enabled: true
})
const apiBase = import.meta.env.VITE_API_BASE || '/api'
function getAuthHeader(): string {
return sessionStorage.getItem('admin_auth') || ''
}
// Actions
async function login() {
loginError.value = ''
const auth = 'Basic ' + btoa(loginForm.value.username + ':' + loginForm.value.password)
try {
const res = await fetch(`${apiBase}/admin/providers`, {
headers: { Authorization: auth },
})
if (res.ok) {
sessionStorage.setItem('admin_auth', auth)
await loadData()
} else {
loginError.value = '鉴权失败'
}
} catch {
loginError.value = '网络不可达'
}
}
function logout() {
sessionStorage.removeItem('admin_auth')
providers.value = []
}
async function loadData() {
if (!isLoggedIn.value) return
const headers = { Authorization: getAuthHeader() }
// Parallel fetch
const [pRes, dRes, aRes] = await Promise.all([
fetch(`${apiBase}/admin/providers`, { headers }),
fetch(`${apiBase}/admin/providers/defaults`, { headers }),
fetch(`${apiBase}/admin/providers/adapters`, { headers })
])
if (pRes.ok) providers.value = await pRes.json()
if (dRes.ok) defaults.value = await dRes.json()
if (aRes.ok) availableAdapters.value = await aRes.json()
}
// Computed
const filteredProviders = computed(() => {
return providers.value
.filter(p => p.type === activeTab.value)
.sort((a, b) => b.priority - a.priority)
})
const adapterOptions = computed(() => {
return availableAdapters.value
.filter(a => a.startsWith(activeTab.value + ':'))
.map(a => {
const name = a.split(':')[1]
return { value: name, label: name } // e.g. 'gemini', 'openai'
})
})
// UI Actions
function openCreateModal() {
form.value = {
type: activeTab.value,
priority: 10,
enabled: true,
weight: 1,
timeout_ms: 60000,
max_retries: 1
}
editing.value = true
}
function edit(p: Provider) {
const { has_api_key, ...rest } = p
form.value = { ...rest, api_key: '' } // Clear key for security, user re-enters if needed
editing.value = true
}
function reset() {
editing.value = false
form.value = {}
}
async function submit() {
const method = form.value.id ? 'PUT' : 'POST'
const url = form.value.id
? `${apiBase}/admin/providers/${form.value.id}`
: `${apiBase}/admin/providers`
await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader()
},
body: JSON.stringify(form.value)
})
await loadData()
reset()
}
async function remove(p: Provider) {
if(!confirm(`确认删除 ${p.name}?`)) return
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'DELETE',
headers: { Authorization: getAuthHeader() }
})
await loadData()
}
async function toggleEnabled(p: Provider) {
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader()
},
body: JSON.stringify({ enabled: !p.enabled })
})
await loadData()
}
onMounted(() => {
if (isLoggedIn.value) loadData()
})
</script>

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
import BaseSelect from '../components/ui/BaseSelect.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import MemoryList, { type MemoryItem } from '../components/MemoryList.vue'
import AddMemoryModal from '../components/AddMemoryModal.vue'
import { ExclamationCircleIcon, UserCircleIcon, SparklesIcon, PlusIcon } from '@heroicons/vue/24/outline'
interface ChildProfile {
id: string
name: string
avatar_url: string | null
birth_date: string | null
gender: string | null
age: number | null
interests: string[]
growth_themes: string[]
stories_count: number
total_reading_time: number
}
interface MemoryListResponse {
memories: MemoryItem[]
total: number
}
const route = useRoute()
const router = useRouter()
const profile = ref<ChildProfile | null>(null)
const loading = ref(true)
const error = ref('')
// 记忆相关状态
const memories = ref<MemoryItem[]>([])
const loadingMemories = ref(true)
const showAddMemoryModal = ref(false)
const form = ref({
name: '',
birth_date: '',
gender: '',
interests: '',
growth_themes: '',
})
function parseTags(input: string) {
return input
.split(/[,]/)
.map(tag => tag.trim())
.filter(Boolean)
}
function fillForm(data: ChildProfile) {
form.value = {
name: data.name,
birth_date: data.birth_date || '',
gender: data.gender || '',
interests: data.interests.join('、'),
growth_themes: data.growth_themes.join('、'),
}
}
async function fetchProfile() {
loading.value = true
error.value = ''
try {
const data = await api.get<ChildProfile>(`/api/profiles/${route.params.id}`)
profile.value = data
fillForm(data)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function fetchMemories() {
loadingMemories.value = true
try {
const data = await api.get<MemoryListResponse>(`/api/profiles/${route.params.id}/memories`)
memories.value = data.memories
} catch (e) {
console.error('获取记忆失败:', e)
} finally {
loadingMemories.value = false
}
}
async function handleDeleteMemory(id: string) {
try {
await api.delete(`/api/profiles/${route.params.id}/memories/${id}`)
memories.value = memories.value.filter(m => m.id !== id)
} catch (e) {
console.error('删除记忆失败:', e)
}
}
async function handleAddMemory(data: { type: string; value: Record<string, unknown> }) {
try {
// 使用合适的端点 based on type
let newMemory: MemoryItem
if (data.type === 'favorite_character') {
newMemory = await api.post<MemoryItem>(`/api/profiles/${route.params.id}/memories/character`, {
name: data.value.name,
description: data.value.description || null,
})
} else if (data.type === 'scary_element') {
newMemory = await api.post<MemoryItem>(`/api/profiles/${route.params.id}/memories/scary`, {
keyword: data.value.keyword,
category: data.value.category || 'other',
})
} else {
// 通用类型
newMemory = await api.post<MemoryItem>(`/api/profiles/${route.params.id}/memories`, {
type: data.type,
value: data.value,
})
}
memories.value.unshift(newMemory)
showAddMemoryModal.value = false
} catch (e) {
console.error('添加记忆失败:', e)
}
}
async function updateProfile() {
if (!form.value.name.trim()) {
error.value = '姓名不能为空'
return
}
error.value = ''
try {
const data = await api.put<ChildProfile>(`/api/profiles/${route.params.id}`, {
name: form.value.name.trim(),
birth_date: form.value.birth_date || undefined,
gender: form.value.gender || undefined,
interests: parseTags(form.value.interests),
growth_themes: parseTags(form.value.growth_themes),
})
profile.value = data
fillForm(data)
} catch (e) {
error.value = e instanceof Error ? e.message : '更新失败'
}
}
async function deleteProfile() {
if (!window.confirm('确定删除这个档案吗?')) return
try {
await api.delete(`/api/profiles/${route.params.id}`)
router.push('/profiles')
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
onMounted(() => {
fetchProfile()
fetchMemories()
})
</script>
<template>
<div class="max-w-3xl mx-auto px-4">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">档案详情</h1>
<p class="text-gray-500">查看并编辑孩子档案</p>
</div>
<div class="flex gap-4">
<BaseButton as="router-link" :to="`/profiles/${route.params.id}/timeline`" class="bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all">
查看成长足迹
</BaseButton>
<BaseButton as="router-link" to="/profiles" variant="ghost" class="text-purple-600">
返回列表
</BaseButton>
</div>
</div>
<div v-if="loading" class="py-10">
<LoadingSpinner text="加载中..." />
</div>
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
title="加载失败"
:description="error"
/>
</div>
<div v-else-if="profile" class="space-y-6">
<BaseCard>
<div class="flex items-center space-x-4">
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-purple-400 to-pink-400 text-white font-bold flex items-center justify-center">
{{ profile.name.charAt(0) }}
</div>
<div>
<div class="text-xl font-semibold text-gray-800">{{ profile.name }}</div>
<div class="text-sm text-gray-500">{{ profile.age ?? '未知' }} · {{ profile.gender ?? '未设置' }}</div>
</div>
</div>
</BaseCard>
<BaseCard>
<div class="flex items-center gap-2 mb-4">
<UserCircleIcon class="h-5 w-5 text-purple-500" />
<h2 class="text-lg font-semibold text-gray-700">编辑信息</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseInput v-model="form.name" placeholder="孩子姓名" />
<BaseInput v-model="form.birth_date" type="date" />
<BaseSelect
v-model="form.gender"
:options="[
{ value: '', label: '性别(可选)' },
{ value: 'male', label: '男' },
{ value: 'female', label: '女' },
{ value: 'other', label: '其他' },
]"
/>
<BaseInput v-model="form.interests" placeholder="兴趣标签(逗号分隔)" />
<BaseInput v-model="form.growth_themes" placeholder="成长主题(逗号分隔)" class="md:col-span-2" />
</div>
<div class="mt-4 flex items-center justify-between">
<span v-if="error" class="text-sm text-red-500">{{ error }}</span>
<div class="flex gap-3">
<BaseButton @click="updateProfile">保存</BaseButton>
<BaseButton variant="secondary" class="text-red-500 border-red-200" @click="deleteProfile">
删除档案
</BaseButton>
</div>
</div>
</BaseCard>
<!-- 记忆管理区块 -->
<BaseCard>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<SparklesIcon class="h-5 w-5 text-purple-500" />
<h2 class="text-lg font-semibold text-gray-700">记忆管理</h2>
</div>
<BaseButton size="sm" @click="showAddMemoryModal = true">
<PlusIcon class="h-4 w-4 mr-1" />
添加记忆
</BaseButton>
</div>
<MemoryList
:memories="memories"
:loading="loadingMemories"
@delete="handleDeleteMemory"
/>
</BaseCard>
</div>
<!-- 添加记忆模态框 -->
<AddMemoryModal
:show="showAddMemoryModal"
@close="showAddMemoryModal = false"
@submit="handleAddMemory"
/>
</div>
</template>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import {
SparklesIcon,
BookOpenIcon,
TrophyIcon,
FlagIcon,
CalendarIcon,
ChevronLeftIcon,
ExclamationCircleIcon
} from '@heroicons/vue/24/solid'
interface TimelineEvent {
date: string
type: 'story' | 'achievement' | 'milestone'
title: string
description: string | null
image_url: string | null
metadata: {
story_id?: number
mode?: string
[key: string]: any
} | null
}
interface TimelineResponse {
events: TimelineEvent[]
}
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
const events = ref<TimelineEvent[]>([])
const profileId = route.params.id as string
const profileName = ref('') // We might need to fetch profile details separately or store it
function getIcon(type: string) {
switch (type) {
case 'milestone': return FlagIcon
case 'story': return BookOpenIcon
case 'achievement': return TrophyIcon
default: return SparklesIcon
}
}
function getColor(type: string) {
switch (type) {
case 'milestone': return 'text-blue-500'
case 'story': return 'text-purple-500'
case 'achievement': return 'text-yellow-500'
default: return 'text-gray-500'
}
}
function formatDate(isoStr: string) {
const date = new Date(isoStr)
return date.toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
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
const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`)
events.value = data.events
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
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}`)
}
}
}
onMounted(fetchTimeline)
</script>
<template>
<div class="min-h-screen bg-slate-50 relative overflow-x-hidden font-sans">
<!-- 背景装饰 -->
<div class="absolute inset-0 z-0 pointer-events-none">
<div class="fixed top-0 right-0 w-96 h-96 bg-purple-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
<div class="fixed bottom-0 left-0 w-96 h-96 bg-pink-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
</div>
<!-- 顶部导航 -->
<div class="relative z-10 max-w-4xl mx-auto px-4 py-8">
<BaseButton as="router-link" :to="`/profiles/${profileId}`" variant="secondary" class="mb-8 flex w-32 items-center justify-center gap-2 shadow-sm hover:shadow-md transition-shadow">
<ChevronLeftIcon class="h-4 w-4" /> 返回档案
</BaseButton>
<div v-if="loading" class="py-20">
<LoadingSpinner text="正在追溯时光..." />
</div>
<div v-else-if="error" class="py-20">
<EmptyState
:icon="ExclamationCircleIcon"
title="出错了"
:description="error"
/>
</div>
<template v-else>
<div class="text-center mb-16 animate-fade-in-down">
<h1 class="text-4xl md:text-5xl font-extrabold gradient-text mb-4 tracking-tight">成长足迹</h1>
<p v-if="profileName" class="text-xl text-gray-600 font-medium"> {{ profileName }} 的奇妙冒险之旅 </p>
</div>
<!-- 暂无数据 -->
<div v-if="events.length === 0" class="text-center py-20 bg-white/50 backdrop-blur rounded-3xl border border-white">
<SparklesIcon class="h-16 w-16 text-purple-300 mx-auto mb-4" />
<p class="text-xl text-gray-500">还没有开始冒险呢快去创作第一个故事吧</p>
</div>
<!-- 时间轴内容 -->
<div v-else class="relative pb-20">
<!-- 垂直线 -->
<div class="absolute left-4 md:left-1/2 md:transform md:-translate-x-1/2 top-4 bottom-0 w-1 bg-gradient-to-b from-purple-400 via-pink-400 to-blue-400 rounded-full opacity-40"></div>
<!-- 事件列表 -->
<div v-for="(event, index) in events" :key="index"
class="mb-12 flex flex-col md:flex-row items-center w-full group relative"
:class="index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'"
>
<!-- 宽度占位 (Desktop) -->
<div class="hidden md:block md:w-5/12"></div>
<!-- 中轴点 -->
<div class="z-20 absolute left-2 md:static flex items-center justify-center border-4 border-white shadow-lg shrink-0 group-hover:scale-110 transition-transform duration-300 w-6 h-6 rounded-full md:w-10 md:h-10 bg-white ring-4 ring-purple-100">
<component :is="getIcon(event.type)" class="h-3 w-3 md:h-5 md:w-5" :class="getColor(event.type)" />
</div>
<!-- 卡片 -->
<div class="w-full pl-12 md:pl-0 md:w-5/12 order-1" :class="index % 2 === 0 ? 'md:text-right md:pr-12' : 'md:text-left md:pl-12'">
<div
class="bg-white/90 backdrop-blur-md p-6 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/60 transform hover:-translate-y-1 cursor-pointer overflow-hidden relative"
@click="handleEventClick(event)"
>
<!-- 装饰背景 -->
<div class="absolute -right-10 -top-10 w-24 h-24 bg-gradient-to-br from-purple-100 to-transparent rounded-full opacity-50"></div>
<div class="flex items-center gap-2 mb-3 text-sm text-gray-500 font-semibold uppercase tracking-wider" :class="index % 2 === 0 ? 'md:justify-end' : 'md:justify-start'">
<CalendarIcon class="h-4 w-4 text-purple-400" />
{{ formatDate(event.date) }}
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2 group-hover:text-purple-600 transition-colors">{{ event.title }}</h3>
<p v-if="event.description" class="text-gray-600 text-sm leading-relaxed mb-4">{{ event.description }}</p>
<div v-if="event.image_url" class="relative mt-4 aspect-video rounded-xl overflow-hidden shadow-md group-hover:shadow-lg transition-shadow">
<img :src="event.image_url" loading="lazy" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" />
<div class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
</div>
<!-- Role Badge -->
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600;
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-fade-in-down {
animation: fadeInDown 0.8s ease-out;
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
import BaseSelect from '../components/ui/BaseSelect.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { ExclamationCircleIcon, UserGroupIcon } from '@heroicons/vue/24/outline'
interface ChildProfile {
id: string
name: string
avatar_url: string | null
birth_date: string | null
gender: string | null
age: number | null
interests: string[]
growth_themes: string[]
stories_count: number
total_reading_time: number
}
interface ProfileListResponse {
profiles: ChildProfile[]
total: number
}
const profiles = ref<ChildProfile[]>([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const form = ref({
name: '',
birth_date: '',
gender: '',
interests: '',
growth_themes: '',
})
function parseTags(input: string) {
return input
.split(/[,]/)
.map(tag => tag.trim())
.filter(Boolean)
}
async function fetchProfiles() {
loading.value = true
error.value = ''
try {
const data = await api.get<ProfileListResponse>('/api/profiles')
profiles.value = data.profiles
total.value = data.total
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createProfile() {
if (!form.value.name.trim()) {
error.value = '请输入孩子姓名'
return
}
error.value = ''
try {
await api.post<ChildProfile>('/api/profiles', {
name: form.value.name.trim(),
birth_date: form.value.birth_date || undefined,
gender: form.value.gender || undefined,
interests: parseTags(form.value.interests),
growth_themes: parseTags(form.value.growth_themes),
})
form.value = {
name: '',
birth_date: '',
gender: '',
interests: '',
growth_themes: '',
}
await fetchProfiles()
} catch (e) {
error.value = e instanceof Error ? e.message : '创建失败'
}
}
onMounted(fetchProfiles)
</script>
<template>
<div class="max-w-5xl mx-auto px-4">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">孩子档案</h1>
<p class="text-gray-500">为每个孩子建立专属档案</p>
</div>
<div class="text-sm text-gray-500"> {{ total }} 个档案</div>
</div>
<BaseCard class="mb-8" padding="lg">
<h2 class="text-lg font-semibold text-gray-700 mb-4">创建新档案</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseInput v-model="form.name" placeholder="孩子姓名" />
<BaseInput v-model="form.birth_date" type="date" />
<BaseSelect
v-model="form.gender"
:options="[
{ value: '', label: '性别(可选)' },
{ value: 'male', label: '男' },
{ value: 'female', label: '女' },
{ value: 'other', label: '其他' },
]"
/>
<BaseInput v-model="form.interests" placeholder="兴趣标签(逗号分隔)" />
<BaseInput v-model="form.growth_themes" placeholder="成长主题(逗号分隔)" class="md:col-span-2" />
</div>
<div class="mt-4 flex items-center justify-between">
<span v-if="error" class="text-sm text-red-500">{{ error }}</span>
<BaseButton @click="createProfile">创建档案</BaseButton>
</div>
</BaseCard>
<div v-if="loading" class="py-10">
<LoadingSpinner text="加载中..." />
</div>
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
title="加载失败"
:description="error"
/>
</div>
<div v-else-if="profiles.length === 0" class="py-10">
<EmptyState
:icon="UserGroupIcon"
title="暂无档案"
description="创建你的第一个孩子档案"
/>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link
v-for="profile in profiles"
:key="profile.id"
:to="`/profiles/${profile.id}`"
class="block"
>
<BaseCard hover>
<div class="flex items-center space-x-3">
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-purple-400 to-pink-400 text-white font-bold flex items-center justify-center">
{{ profile.name.charAt(0) }}
</div>
<div>
<div class="font-semibold text-gray-800">{{ profile.name }}</div>
<div class="text-sm text-gray-500">{{ profile.age ?? '未知' }} </div>
</div>
</div>
<div class="mt-4 text-sm text-gray-500">
兴趣{{ profile.interests.length ? profile.interests.join('、') : '未设置' }}
</div>
<div class="mt-1 text-sm text-gray-500">
成长主题{{ profile.growth_themes.length ? profile.growth_themes.join('、') : '未设置' }}
</div>
</BaseCard>
</router-link>
</div>
</div>
</template>

473
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,473 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user'
import BaseButton from '../components/ui/BaseButton.vue'
import LoginDialog from '../components/ui/LoginDialog.vue'
import {
SparklesIcon,
ArrowRightOnRectangleIcon
} from '@heroicons/vue/24/outline'
const { locale } = useI18n()
const router = useRouter()
const userStore = useUserStore()
// ========== 导航栏状态 ==========
const showUserMenu = ref(false)
function switchLocale(lang: 'en' | 'zh') {
locale.value = lang
localStorage.setItem('locale', lang)
}
// ========== 登录对话框状态 ==========
const showLoginDialog = ref(false)
// ========== 创作入口 ==========
// 旧的创作变量已移除,现在只负责跳转
function openCreateModal() {
if (!userStore.user) {
showLoginDialog.value = true
return
}
// 跳转到后台创作
router.push({ path: '/my-stories', query: { openCreate: 'true' } })
}
function scrollToFeatures() {
document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })
}
// ========== 统计数据 (静态模拟) ==========
// const storiesCount = 10000
// const familiesCount = 5000
// const satisfactionCount = 99
</script>
<template>
<div class="landing-page min-h-screen flex flex-col">
<!-- ========== 导航栏 ========== -->
<nav class="sticky top-0 z-50 bg-[#FDFBF7]/90 backdrop-blur-md border-b border-stone-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<router-link to="/" class="text-2xl font-bold tracking-tight text-amber-600 flex items-center gap-2">
<SparklesIcon class="w-6 h-6" />
<span>梦语织机</span>
</router-link>
<div class="hidden md:flex space-x-8">
<a href="#features" class="text-stone-600 hover:text-amber-600 font-medium transition-colors">功能</a>
<a href="#how-it-works" class="text-stone-600 hover:text-amber-600 font-medium transition-colors">使用方法</a>
<a href="#faq" class="text-stone-600 hover:text-amber-600 font-medium transition-colors">常见问题</a>
</div>
<div class="flex items-center space-x-4">
<div class="flex items-center bg-white rounded-lg border border-stone-200 p-1">
<button
class="px-2 py-1 text-xs font-medium rounded-md transition-colors"
:class="locale === 'en' ? 'bg-stone-100 text-stone-900' : 'text-stone-500 hover:text-stone-900'"
@click="switchLocale('en')"
>
EN
</button>
<button
class="px-2 py-1 text-xs font-medium rounded-md transition-colors"
:class="locale === 'zh' ? 'bg-amber-100 text-amber-900' : 'text-stone-500 hover:text-stone-900'"
@click="switchLocale('zh')"
>
中文
</button>
</div>
<template v-if="userStore.user">
<div class="relative">
<button class="flex items-center space-x-2 text-stone-700 hover:text-amber-600 transition-colors" @click="showUserMenu = !showUserMenu">
<img
v-if="userStore.user.avatar_url"
:src="userStore.user.avatar_url"
:alt="userStore.user.name"
class="w-8 h-8 rounded-full border border-stone-200"
/>
<div v-else class="w-8 h-8 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center font-bold">
{{ userStore.user.name.charAt(0) }}
</div>
<span class="font-medium hidden sm:inline">{{ userStore.user.name }}</span>
</button>
<div v-if="showUserMenu" class="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-stone-100 py-1 origin-top-right transform transition-all z-50">
<div class="px-4 py-2 border-b border-stone-50">
<p class="text-sm text-stone-500">已登录为</p>
<p class="text-sm font-medium text-stone-900 truncate">{{ userStore.user.name }}</p>
</div>
<router-link to="/my-stories" class="block px-4 py-2 text-sm text-stone-700 hover:bg-stone-50">我的故事</router-link>
<router-link to="/profiles" class="block px-4 py-2 text-sm text-stone-700 hover:bg-stone-50">孩子档案</router-link>
<button @click="userStore.logout(); showUserMenu = false" class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-stone-50 flex items-center gap-2">
<ArrowRightOnRectangleIcon class="w-4 h-4" />
退出登录
</button>
</div>
</div>
</template>
<template v-else>
<BaseButton size="sm" @click="showLoginDialog = true">登录 / 注册</BaseButton>
</template>
</div>
</div>
</div>
</nav>
<div v-if="showUserMenu" class="fixed inset-0 z-40" @click="showUserMenu = false"></div>
<!-- ========== Hero Section ========== -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24 lg:py-32">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div>
<div class="inline-flex items-center gap-2 px-3 py-1 bg-amber-50 text-amber-700 text-sm font-medium rounded-full mb-6">
<span></span> 专为 3-8 岁孩子设计的魔法故事机
</div>
<h1 class="text-5xl md:text-6xl font-bold text-stone-900 mb-6 leading-tight">
为孩子编织
<span class="text-amber-600">温暖的童年记忆</span>
</h1>
<p class="text-xl text-stone-600 mb-8 leading-relaxed">
每一个孩子都是天生的梦想家我们用 AI 科技将天马行空的想象编织成独一无二的有声绘本陪伴孩子快乐成长
</p>
<div class="flex flex-col sm:flex-row gap-4">
<BaseButton size="lg" @click="openCreateModal" class="shadow-xl shadow-amber-200/50">
<SparklesIcon class="h-5 w-5 mr-2" />
开始创作故事
</BaseButton>
<button @click="scrollToFeatures" class="px-6 py-3 rounded-xl font-semibold text-stone-600 bg-white border border-stone-200 hover:border-amber-400 hover:text-amber-700 transition-all shadow-sm">
了解更多功能
</button>
</div>
<!-- Trust Indicators -->
<div class="mt-12 flex items-center gap-8 text-stone-500">
<div class="flex -space-x-2">
<div class="w-8 h-8 rounded-full bg-stone-200 border-2 border-white"></div>
<div class="w-8 h-8 rounded-full bg-stone-300 border-2 border-white"></div>
<div class="w-8 h-8 rounded-full bg-stone-400 border-2 border-white"></div>
<div class="w-8 h-8 rounded-full bg-stone-100 border-2 border-white flex items-center justify-center text-xs font-bold">+2k</div>
</div>
<div class="text-sm">
已有 <span class="font-bold text-stone-800">5,000+</span> 个家庭正在使用
</div>
</div>
</div>
<!-- Hero Visual -->
<div class="relative">
<!-- 背景装饰圆 -->
<div class="absolute top-0 right-0 w-72 h-72 bg-amber-100 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob"></div>
<div class="absolute bottom-0 left-0 w-72 h-72 bg-orange-100 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-2000"></div>
<!-- Preview Card -->
<div class="relative bg-white p-6 rounded-2xl shadow-xl transform rotate-1 hover:rotate-0 transition-transform duration-500 border border-stone-100">
<div class="aspect-[4/3] bg-stone-100 rounded-xl mb-4 overflow-hidden relative group">
<!-- Placeholder Image -->
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618331835717-801e976710b2?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80')] bg-cover bg-center opacity-80 group-hover:scale-105 transition-transform duration-700"></div>
<div class="absolute bottom-4 right-4 bg-white/90 backdrop-blur px-3 py-1 rounded-full text-xs font-bold text-stone-800 shadow-sm">
绘本插画
</div>
</div>
<h3 class="text-xl font-bold text-stone-900 mb-2">小狐狸的第一次探险</h3>
<p class="text-stone-500 text-sm leading-relaxed mb-4">
这是一个关于勇气和友谊的故事小狐狸第一次离开家在森林里遇到了需要帮助的小松鼠...
</p>
<div class="flex items-center justify-between">
<div class="flex gap-2">
<span class="px-2 py-1 bg-amber-50 text-amber-700 text-xs font-bold rounded-lg">勇气</span>
<span class="px-2 py-1 bg-green-50 text-green-700 text-xs font-bold rounded-lg">友谊</span>
</div>
<div class="w-8 h-8 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center">
<SparklesIcon class="w-4 h-4" />
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ========== Features Section ========== -->
<section id="features" class="py-24 bg-white scroll-mt-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-stone-900 mb-4">为什么选择梦语织机</h2>
<p class="text-lg text-stone-600 max-w-2xl mx-auto">我们不仅仅是在生成故事更是在为孩子创造一个安全温暖富有教育意义的成长空间</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="p-8 rounded-2xl bg-[#FDFBF7] border border-stone-100 hover:shadow-lg transition-shadow">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center mb-6 text-2xl">🎨</div>
<h3 class="text-xl font-bold text-stone-900 mb-3">AI 绘本创作</h3>
<p class="text-stone-600">根据故事内容自动生成精美插画让文字活起来培养孩子的艺术审美</p>
</div>
<div class="p-8 rounded-2xl bg-[#FDFBF7] border border-stone-100 hover:shadow-lg transition-shadow">
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center mb-6 text-2xl">🌱</div>
<h3 class="text-xl font-bold text-stone-900 mb-3">个性化成长档案</h3>
<p class="text-stone-600">为每个孩子定制专属的主角人设将性格培养和习惯养成融入故事之中</p>
</div>
<div class="p-8 rounded-2xl bg-[#FDFBF7] border border-stone-100 hover:shadow-lg transition-shadow">
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mb-6 text-2xl">🎙</div>
<h3 class="text-xl font-bold text-stone-900 mb-3">温暖语音陪伴</h3>
<p class="text-stone-600">像爸爸妈妈一样的温柔讲述无论何时何地都能给孩子最长情的陪伴</p>
</div>
</div>
</div>
</section>
<!-- ========== How It Works Section ========== -->
<section id="how-it-works" class="py-24 bg-[#FDFBF7] scroll-mt-16 relative overflow-hidden">
<!-- Background decoration -->
<div class="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-stone-200 to-transparent"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-stone-900 mb-4">只需三步开启奇妙旅程</h2>
<p class="text-lg text-stone-600">零门槛操作让创意的火花瞬间变成精彩的故事</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-12 relative">
<!-- Connector Line (Desktop) -->
<div class="hidden md:block absolute top-12 left-[16%] right-[16%] h-0.5 bg-stone-200 -z-10"></div>
<!-- Step 1 -->
<div class="text-center group">
<div class="w-24 h-24 mx-auto bg-white border border-stone-200 rounded-full flex items-center justify-center mb-6 shadow-sm group-hover:scale-110 transition-transform duration-300 relative z-10">
<span class="text-4xl">📝</span>
<div class="absolute -top-2 -right-2 w-8 h-8 bg-amber-500 text-white rounded-full flex items-center justify-center font-bold border-4 border-[#FDFBF7]">1</div>
</div>
<h3 class="text-xl font-bold text-stone-900 mb-2">建立档案</h3>
<p class="text-stone-600">输入孩子的名字年龄和兴趣让故事里的主角就是他自己</p>
</div>
<!-- Step 2 -->
<div class="text-center group">
<div class="w-24 h-24 mx-auto bg-white border border-stone-200 rounded-full flex items-center justify-center mb-6 shadow-sm group-hover:scale-110 transition-transform duration-300 relative z-10">
<span class="text-4xl"></span>
<div class="absolute -top-2 -right-2 w-8 h-8 bg-amber-500 text-white rounded-full flex items-center justify-center font-bold border-4 border-[#FDFBF7]">2</div>
</div>
<h3 class="text-xl font-bold text-stone-900 mb-2">输入灵感</h3>
<p class="text-stone-600">"想做一个关于勇敢的小恐龙的故事"一句话告诉 AI 你的想法</p>
</div>
<!-- Step 3 -->
<div class="text-center group">
<div class="w-24 h-24 mx-auto bg-white border border-stone-200 rounded-full flex items-center justify-center mb-6 shadow-sm group-hover:scale-110 transition-transform duration-300 relative z-10">
<span class="text-4xl">📖</span>
<div class="absolute -top-2 -right-2 w-8 h-8 bg-amber-500 text-white rounded-full flex items-center justify-center font-bold border-4 border-[#FDFBF7]">3</div>
</div>
<h3 class="text-xl font-bold text-stone-900 mb-2">生成绘本</h3>
<p class="text-stone-600">稍等片刻一个图文并茂配有语音的专属绘本就诞生了</p>
</div>
</div>
</div>
</section>
<!-- ========== Testimonials Section (家长评价) ========== -->
<section id="testimonials" class="py-24 bg-white scroll-mt-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-stone-900 mb-4">听听家长们怎么说</h2>
<p class="text-lg text-stone-600">超过 5000 个家庭正在使用梦语织机陪伴孩子成长</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Review 1 -->
<div class="p-8 bg-[#FDFBF7] rounded-3xl border border-stone-100 relative">
<div class="absolute -top-4 left-8 text-6xl text-amber-200">"</div>
<p class="text-stone-700 italic mb-6 relative z-10">
自从有了梦语织机,每天晚上的睡前时光都成了我和女儿最期待的时刻。她最喜欢把自己变成故事里的魔法公主,看到她眼里闪着光,我也觉得好幸福。
</p>
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-pink-100 flex items-center justify-center text-xl">👩</div>
<div>
<div class="font-bold text-stone-900">张雨涵妈妈</div>
<div class="text-xs text-stone-500">5岁女孩的母亲</div>
</div>
</div>
</div>
<!-- Review 2 -->
<div class="p-8 bg-[#FDFBF7] rounded-3xl border border-stone-100 relative">
<div class="absolute -top-4 left-8 text-6xl text-amber-200">"</div>
<p class="text-stone-700 italic mb-6 relative z-10">
工作太忙以前总是没时间给儿子编故事现在我只需要输入一个想法AI 就能帮我生成一个完整又富有教育意义的故事而且声音特别温柔简直是哄睡神器
</p>
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-xl">👨</div>
<div>
<div class="font-bold text-stone-900">李强爸爸</div>
<div class="text-xs text-stone-500">4岁男孩的父亲</div>
</div>
</div>
</div>
<!-- Review 3 -->
<div class="p-8 bg-[#FDFBF7] rounded-3xl border border-stone-100 relative">
<div class="absolute -top-4 left-8 text-6xl text-amber-200">"</div>
<p class="text-stone-700 italic mb-6 relative z-10">
作为幼儿园老师,我经常用它来生成针对性的教育故事。比如班里有小朋友不爱刷牙,我就做了一个《牙齿王国的保卫战》,孩子们特别吃这一套!效果满分。
</p>
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center text-xl">🧑‍🏫</div>
<div>
<div class="font-bold text-stone-900">王老师</div>
<div class="text-xs text-stone-500">资深幼教</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ========== Story Gallery (精选绘本展) ========== -->
<section id="gallery" class="py-24 bg-[#FDFBF7] relative overflow-hidden">
<!-- 装饰背景 -->
<div class="absolute top-0 inset-x-0 h-px bg-stone-200"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="flex flex-col md:flex-row items-end justify-between mb-12 gap-6">
<div>
<h2 class="text-3xl font-bold text-stone-900 mb-2">探索无限可能</h2>
<p class="text-stone-600">从奇幻冒险到温馨日常,每一个故事都是独一无二的宝藏</p>
</div>
<BaseButton class="shrink-0" @click="openCreateModal">我也要创作</BaseButton>
</div>
<!-- 滚动展示区 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<!-- Book 1 -->
<div class="group cursor-pointer">
<div class="aspect-[3/4] rounded-2xl bg-amber-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1535905557558-afc4877a26fc?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
<div class="absolute bottom-0 inset-x-0 p-4 bg-gradient-to-t from-black/60 to-transparent text-white opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-sm font-bold">阅读故事 &rarr;</span>
</div>
</div>
<h3 class="font-bold text-stone-800 text-lg">魔法书店的奇妙夜</h3>
<p class="text-xs text-stone-500">奇幻 • 想象力</p>
</div>
<!-- Book 2 -->
<div class="group cursor-pointer">
<div class="aspect-[3/4] rounded-2xl bg-blue-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1459369510627-9efbee1e6051?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
</div>
<h3 class="font-bold text-stone-800 text-lg">小熊的蜂蜜罐</h3>
<p class="text-xs text-stone-500">分享 • 友谊</p>
</div>
<!-- Book 3 -->
<div class="group cursor-pointer">
<div class="aspect-[3/4] rounded-2xl bg-green-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1503919005314-30d93d07d823?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
</div>
<h3 class="font-bold text-stone-800 text-lg">森林里的音乐会</h3>
<p class="text-xs text-stone-500">艺术 • 自在</p>
</div>
<!-- Book 4 -->
<div class="group cursor-pointer">
<div class="aspect-[3/4] rounded-2xl bg-purple-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1534447677768-be436bb09401?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
</div>
<h3 class="font-bold text-stone-800 text-lg">冲向月球!</h3>
<p class="text-xs text-stone-500">科学 • 探索</p>
</div>
</div>
</div>
</section>
<!-- ========== FAQ Section ========== -->
<section id="faq" class="py-24 bg-white scroll-mt-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-stone-900 mb-4">常见问题解答</h2>
<p class="text-lg text-stone-600">这里是关于使用梦语织机的一些详细解答</p>
</div>
<div class="space-y-4">
<!-- FAQ 1: Customization -->
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
我可以把孩子设为故事主角吗?
<span class="transform group-open:rotate-180 transition-transform">▼</span>
</summary>
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
当然可以!这是我们最核心的功能。您可以在"孩子档案"中设置孩子的名字、年龄、性格特点AI 会根据这些信息量身定制故事,让孩子在故事中看到自己的影子,代入感极强。
</div>
</details>
<!-- FAQ 2: Voice -->
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
生成的故事有语音朗读吗?
<span class="transform group-open:rotate-180 transition-transform">▼</span>
</summary>
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
是的,我们采用最先进的 TTS文本转语音技术能够生成媲美真人的情感语音。您可以选择不同的讲述人音色如温柔妈妈、磁性爸爸等让故事听起来生动有趣。
</div>
</details>
<!-- FAQ 3: Education -->
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
可以设定特定的教育目标吗?比如"如果不爱吃蔬菜"
<span class="transform group-open:rotate-180 transition-transform">▼</span>
</summary>
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
没问题!在创作故事时,您可以选择或者自定义"教育主题"。例如输入"教孩子为什么要吃蔬菜"或者"如何克服怕黑的心理"AI 会巧妙地将这些道理融入有趣的剧情中,避免生硬的说教。
</div>
</details>
<!-- FAQ 4: Download/Print -->
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
生成的绘本可以下载打印吗?
<span class="transform group-open:rotate-180 transition-transform">▼</span>
</summary>
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
支持。对于会员用户,我们提供高清 PDF 导出功能。您可以将绘本下载并打印出来,装订成独一无二的实体书,成为孩子珍贵的成长纪念。
</div>
</details>
<!-- FAQ 5: Safety -->
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
故事内容对孩子安全吗?
<span class="transform group-open:rotate-180 transition-transform">▼</span>
</summary>
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
安全是我们最重视的原则。我们的 AI 模型经过严格训练和多重内容过滤,确保输出的内容阳光、积极,绝不包含恐怖、暴力或成人向内容,您可以放心给孩子使用。
</div>
</details>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-stone-50 border-t border-stone-200 py-12 mt-auto">
<div class="max-w-7xl mx-auto px-4 text-center text-stone-500 text-sm">
<p>&copy; 2024 DreamWeaver AI. 用爱编织每一个梦想。</p>
</div>
</footer>
<LoginDialog v-model="showLoginDialog" />
</div>
</template>
<style>
/* Custom animations if needed */
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
</style>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import CreateStoryModal from '../components/CreateStoryModal.vue'
import {
BookOpenIcon,
ChevronRightIcon,
ExclamationCircleIcon,
PhotoIcon,
SparklesIcon,
PlusIcon,
} from '@heroicons/vue/24/outline'
interface StoryItem {
id: number
title: string
image_url: string | null
created_at: string
}
const router = useRouter()
const stories = ref<StoryItem[]>([])
const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
async function fetchStories() {
try {
stories.value = await api.get<StoryItem[]>('/api/stories')
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function formatDate(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days} 天前`
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function goToCreate() {
showCreateModal.value = true
}
onMounted(() => {
fetchStories()
if (router.currentRoute.value.query.openCreate) {
showCreateModal.value = true
router.replace({ query: { ...router.currentRoute.value.query, openCreate: undefined } })
}
})
</script>
<template>
<div class="max-w-6xl mx-auto px-4">
<!-- 页面标题 -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">我的故事</h1>
<p class="text-gray-500">收藏的所有童话故事</p>
</div>
<BaseButton @click="goToCreate">
<SparklesIcon class="h-5 w-5 mr-2" />
创作新故事
</BaseButton>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="py-20">
<LoadingSpinner text="加载中..." />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
title="加载失败"
:description="error"
/>
</div>
<!-- 空状态 -->
<div v-else-if="stories.length === 0" class="py-10">
<EmptyState
:icon="BookOpenIcon"
title="开始你的创作之旅"
description="还没有创作任何故事,现在就开始为孩子创作第一个专属童话故事吧!"
>
<template #action>
<BaseButton @click="goToCreate">
<PlusIcon class="h-5 w-5 mr-2" />
创作第一个故事
</BaseButton>
</template>
</EmptyState>
</div>
<!-- 故事列表 -->
<template v-else>
<!-- 统计卡片 -->
<BaseCard class="mb-8" padding="lg">
<div class="flex items-center justify-around divide-x divide-gray-100">
<div class="text-center px-4">
<div class="text-3xl font-bold text-gray-800">{{ stories.length }}</div>
<div class="text-gray-500 text-sm mt-1">故事总数</div>
</div>
<div class="text-center px-4">
<div class="text-3xl font-bold text-gray-800">
{{ stories.filter(s => s.image_url).length }}
</div>
<div class="text-gray-500 text-sm mt-1">已配图</div>
</div>
<div class="text-center px-4">
<BookOpenIcon class="h-8 w-8 text-purple-500 mx-auto" />
<div class="text-gray-500 text-sm mt-1">继续阅读</div>
</div>
</div>
</BaseCard>
<!-- 故事网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link
v-for="story in stories"
:key="story.id"
:to="`/story/${story.id}`"
class="block group"
>
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
<!-- 封面图 -->
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
<img
v-if="story.image_url"
:src="story.image_url"
:alt="story.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-gray-300"
>
<PhotoIcon class="h-12 w-12" />
</div>
<!-- 悬停阅读提示 -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span class="inline-flex items-center gap-1 px-4 py-2 bg-white/90 text-gray-900 rounded-full font-medium shadow-lg backdrop-blur-sm transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
阅读故事 <ChevronRightIcon class="h-4 w-4" />
</span>
</div>
</div>
<!-- 信息区 -->
<div class="p-5 flex-1 flex flex-col">
<h3 class="font-bold text-xl text-gray-800 mb-2 line-clamp-2 group-hover:text-purple-600 transition-colors">
{{ story.title }}
</h3>
<div class="mt-auto flex items-center justify-between text-sm text-gray-500">
<span>{{ formatDate(story.created_at) }}</span>
<span v-if="story.image_url" class="flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded text-xs font-medium">
已配图
</span>
</div>
</div>
</BaseCard>
</router-link>
</div>
</template>
<CreateStoryModal v-model="showCreateModal" />
</div>
</template>

View File

@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import ConfirmModal from '../components/ui/ConfirmModal.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import {
ArrowLeftIcon,
ExclamationTriangleIcon,
PhotoIcon,
SpeakerWaveIcon,
SparklesIcon,
TrashIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline'
const route = useRoute()
const router = useRouter()
interface Story {
id: number
title: string
story_text: string
cover_prompt: string | null
image_url: string | null
mode: string
}
const story = ref<Story | null>(null)
const loading = ref(true)
const imageLoading = ref(false)
const audioLoading = ref(false)
const audioUrl = ref<string | null>(null)
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlaying = ref(false)
const audioProgress = ref(0)
const audioDuration = ref(0)
const error = ref('')
const showDeleteConfirm = ref(false)
const imageGenerationFailed = ref(false)
async function fetchStory() {
try {
story.value = await api.get<Story>(`/api/stories/${route.params.id}`)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function generateImage() {
if (!story.value) return
imageLoading.value = true
try {
const result = await api.post<{ image_url: string }>(`/api/image/generate/${story.value.id}`)
story.value.image_url = result.image_url
} catch (e) {
error.value = e instanceof Error ? e.message : '图片生成失败'
} finally {
imageLoading.value = false
}
}
async function loadAudio() {
if (!story.value || audioUrl.value) return
audioLoading.value = true
try {
const response = await fetch(`/api/audio/${story.value.id}`, {
credentials: 'include',
})
if (!response.ok) throw new Error('音频加载失败')
const blob = await response.blob()
audioUrl.value = URL.createObjectURL(blob)
} catch (e) {
error.value = e instanceof Error ? e.message : '音频加载失败'
} finally {
audioLoading.value = false
}
}
function togglePlay() {
if (!audioRef.value) return
if (isPlaying.value) {
audioRef.value.pause()
} else {
audioRef.value.play()
}
isPlaying.value = !isPlaying.value
}
function updateProgress() {
if (!audioRef.value) return
audioProgress.value = audioRef.value.currentTime
audioDuration.value = audioRef.value.duration || 0
}
function seekAudio(e: MouseEvent) {
if (!audioRef.value || !audioDuration.value) return
const rect = (e.target as HTMLElement).getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
audioRef.value.currentTime = percent * audioDuration.value
}
function formatTime(seconds: number) {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
async function deleteStory() {
if (!story.value) return
try {
await api.delete(`/api/stories/${story.value.id}`)
router.push('/my-stories')
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
async function confirmDelete() {
showDeleteConfirm.value = false
await deleteStory()
}
onMounted(() => {
fetchStory()
if (route.query.imageError === '1') {
imageGenerationFailed.value = true
}
})
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
</script>
<template>
<div class="max-w-4xl mx-auto px-4">
<div v-if="loading" class="py-20">
<LoadingSpinner size="lg" text="正在加载故事..." />
</div>
<div v-else-if="error && !story" class="text-center py-20">
<ExclamationTriangleIcon class="h-14 w-14 text-red-400 mx-auto mb-4" />
<p class="text-red-500 text-lg mb-6">{{ error }}</p>
<BaseButton @click="router.push('/')">返回首页</BaseButton>
</div>
<div v-else-if="story" class="space-y-8">
<BaseButton variant="ghost" class="w-fit" @click="router.back()">
<ArrowLeftIcon class="h-5 w-5" />
返回
</BaseButton>
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<div
v-if="imageGenerationFailed && !story?.image_url"
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-xl flex items-center justify-between"
>
<div class="flex items-center space-x-2">
<ExclamationTriangleIcon class="h-5 w-5" />
<span>封面生成失败您可以稍后重试</span>
</div>
<BaseButton
variant="ghost"
size="sm"
class="text-amber-500 hover:text-amber-700"
@click="imageGenerationFailed = false"
>
<XMarkIcon class="h-5 w-5" />
</BaseButton>
</div>
</Transition>
<div class="glass rounded-3xl shadow-2xl overflow-hidden">
<div class="relative aspect-[21/9] bg-gradient-to-br from-purple-100 via-pink-100 to-blue-100 overflow-hidden">
<img
v-if="story.image_url"
:src="story.image_url"
:alt="story.title"
class="w-full h-full object-cover"
/>
<div v-else class="absolute inset-0 flex flex-col items-center justify-center">
<PhotoIcon class="h-16 w-16 text-purple-400 mb-4" />
<BaseButton
variant="secondary"
:loading="imageLoading"
@click="generateImage"
>
<template v-if="imageLoading">AI 正在绘制...</template>
<template v-else>生成精美封面</template>
</BaseButton>
</div>
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white/80 to-transparent"></div>
</div>
<div class="p-8 md:p-12 -mt-16 relative">
<h1 class="text-3xl md:text-4xl font-bold gradient-text mb-8 leading-tight">
{{ story.title }}
</h1>
<div class="prose prose-lg max-w-none mb-10">
<p
v-for="(paragraph, index) in story.story_text.split('\n\n')"
:key="index"
class="text-gray-700 leading-loose mb-6 first-letter:text-4xl first-letter:font-bold first-letter:text-purple-600 first-letter:float-left first-letter:mr-2"
>
{{ paragraph }}
</p>
</div>
<div class="glass rounded-2xl p-6 mb-8">
<div v-if="!audioUrl" class="text-center">
<BaseButton
:loading="audioLoading"
@click="loadAudio"
class="mx-auto"
>
<template v-if="audioLoading">加载中...</template>
<template v-else>
<SpeakerWaveIcon class="h-5 w-5" />
听故事
</template>
</BaseButton>
</div>
<div v-else class="space-y-4">
<audio
ref="audioRef"
:src="audioUrl"
@timeupdate="updateProgress"
@ended="isPlaying = false"
@loadedmetadata="audioDuration = audioRef?.duration || 0"
/>
<div class="flex items-center space-x-4">
<BaseButton
class="w-14 h-14 p-0 rounded-full shadow-lg hover:shadow-xl"
@click="togglePlay"
>
<svg v-if="!isPlaying" class="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</BaseButton>
<div class="flex-1">
<div
class="h-2 bg-gray-200 rounded-full cursor-pointer overflow-hidden"
@click="seekAudio"
>
<div
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 rounded-full transition-all duration-100"
:style="{ width: `${(audioProgress / audioDuration) * 100 || 0}%` }"
></div>
</div>
<div class="flex justify-between text-sm text-gray-500 mt-1">
<span>{{ formatTime(audioProgress) }}</span>
<span>{{ formatTime(audioDuration) }}</span>
</div>
</div>
</div>
</div>
</div>
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<div v-if="error" class="mb-6 p-4 bg-red-50 border border-red-200 text-red-600 rounded-xl">
{{ error }}
</div>
</Transition>
<div class="flex items-center justify-between pt-6 border-t border-gray-100">
<BaseButton as="router-link" to="/" variant="ghost" class="text-purple-600">
<SparklesIcon class="h-5 w-5" />
创作新故事
</BaseButton>
<BaseButton variant="ghost" class="text-red-500" @click="showDeleteConfirm = true">
<TrashIcon class="h-5 w-5" />
删除
</BaseButton>
</div>
</div>
</div>
</div>
<ConfirmModal
:show="showDeleteConfirm"
title="确定删除这个故事吗?"
message="删除后将无法恢复"
confirm-text="确定删除"
cancel-text="取消"
variant="danger"
@confirm="confirmDelete"
@cancel="showDeleteConfirm = false"
/>
</div>
</template>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStorybookStore } from '../stores/storybook'
import BaseButton from '../components/ui/BaseButton.vue'
import {
ArrowLeftIcon,
ArrowRightIcon,
HomeIcon,
BookOpenIcon,
SparklesIcon,
PhotoIcon
} from '@heroicons/vue/24/outline'
const router = useRouter()
const store = useStorybookStore()
const storybook = computed(() => store.currentStorybook)
const currentPageIndex = ref(-1) // -1 for cover
// 计算属性
const totalPages = computed(() => storybook.value?.pages.length || 0)
const isCover = computed(() => currentPageIndex.value === -1)
const isLastPage = computed(() => currentPageIndex.value === totalPages.value - 1)
const currentPage = computed(() => {
if (!storybook.value || isCover.value) return null
return storybook.value.pages[currentPageIndex.value]
})
// 导航
function goHome() {
store.clearStorybook()
router.push('/')
}
function nextPage() {
if (currentPageIndex.value < totalPages.value - 1) {
currentPageIndex.value++
}
}
function prevPage() {
if (currentPageIndex.value > -1) {
currentPageIndex.value--
}
}
onMounted(() => {
if (!storybook.value) {
router.push('/')
}
})
</script>
<template>
<div class="storybook-viewer" v-if="storybook">
<!-- 导航栏 -->
<nav class="fixed top-0 left-0 right-0 z-50 p-4 flex justify-between items-center bg-gradient-to-b from-black/50 to-transparent">
<button @click="goHome" class="p-2 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all">
<HomeIcon class="w-6 h-6" />
</button>
<div class="text-white font-serif text-lg text-shadow">
{{ storybook.title }}
</div>
<div class="w-10"></div> <!-- 占位 -->
</nav>
<!-- 主展示区 -->
<div class="h-screen w-full flex items-center justify-center p-4 md:p-8 relative overflow-hidden">
<!-- 动态背景 -->
<div class="absolute inset-0 bg-[#0D0F1A] z-0">
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
<div class="stars"></div>
</div>
<!-- 书页容器 -->
<div class="book-container relative z-10 w-full max-w-5xl aspect-[16/10] bg-[#fffbf0] rounded-2xl shadow-2xl overflow-hidden flex transition-all duration-500">
<!-- 封面模式 -->
<div v-if="isCover" class="w-full h-full flex flex-col md:flex-row animate-fade-in">
<!-- 封面图 -->
<div class="w-full md:w-1/2 h-1/2 md:h-full relative overflow-hidden bg-gray-900 group">
<template v-if="storybook.cover_url">
<img :src="storybook.cover_url" class="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" />
</template>
<div v-else class="w-full h-full flex flex-col items-center justify-center p-8 text-center bg-gradient-to-br from-indigo-900 to-purple-900 text-white">
<SparklesIcon class="w-20 h-20 mb-4 opacity-50" />
<p class="text-white/60 text-sm">封面正在构思中...</p>
</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">
<span class="inline-block px-3 py-1 bg-yellow-500/90 rounded-full text-xs font-bold mb-2 text-black">绘本故事</span>
</div>
</div>
<!-- 封面信息 -->
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex flex-col justify-center bg-[#fffbf0] text-amber-900">
<div class="hidden md:block mb-8">
<span class="inline-block px-4 py-1 border border-amber-900/30 rounded-full text-sm tracking-widest uppercase">Original Storybook</span>
</div>
<h1 class="text-4xl md:text-6xl font-serif font-bold mb-6 leading-tight">{{ storybook.title }}</h1>
<div class="space-y-4 mb-10 text-amber-900/70">
<p class="flex items-center"><span class="w-20 font-bold opacity-50">主角</span> {{ storybook.main_character }}</p>
<p class="flex items-center"><span class="w-20 font-bold opacity-50">画风</span> {{ storybook.art_style }}</p>
</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" />
</BaseButton>
</div>
</div>
<!-- 内页模式 -->
<div v-else class="w-full h-full flex flex-col md:flex-row animate-fade-in relative">
<!-- 页码 -->
<div class="absolute bottom-4 right-6 text-amber-900/30 font-serif text-xl z-20">
{{ currentPageIndex + 1 }} / {{ totalPages }}
</div>
<!-- 插图区域 () -->
<div class="w-full md:w-1/2 h-1/2 md:h-full relative bg-gray-100 border-r border-amber-900/5">
<template v-if="currentPage?.image_url">
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
</template>
<div v-else class="w-full h-full flex items-center justify-center p-10 bg-white">
<div class="text-center">
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
<PhotoIcon class="w-10 h-10 text-amber-300" />
</div>
<p class="text-amber-900/40 text-sm max-w-xs mx-auto italic">"{{ currentPage?.image_prompt }}"</p>
</div>
</div>
</div>
<!-- 文字区域 () -->
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex items-center justify-center bg-[#fffbf0]">
<div class="prose prose-xl prose-amber font-serif text-amber-900 leading-relaxed text-center md:text-left">
<p>{{ currentPage?.text }}</p>
</div>
</div>
</div>
</div>
<!-- 翻页控制 (悬浮) -->
<button
v-if="!isCover"
@click="prevPage"
class="fixed left-4 md:left-8 top-1/2 -translate-y-1/2 p-3 md:p-4 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all disabled:opacity-30"
>
<ArrowLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
</button>
<button
v-if="!isLastPage"
@click="nextPage"
class="fixed right-4 md:right-8 top-1/2 -translate-y-1/2 p-3 md:p-4 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all shadow-lg"
>
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
</button>
<!-- 最后一页的完成按钮 -->
<BaseButton
v-if="isLastPage"
@click="goHome"
class="fixed right-8 md:right-12 bottom-8 md:bottom-12 shadow-xl"
>
读完了再来一本
</BaseButton>
</div>
</div>
</template>
<style scoped>
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
.book-container {
box-shadow:
0 20px 50px -12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
</style>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
import BaseTextarea from '../components/ui/BaseTextarea.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { ExclamationCircleIcon, GlobeAltIcon, TrophyIcon } from '@heroicons/vue/24/outline'
interface StoryUniverse {
id: string
child_profile_id: string
name: string
protagonist: Record<string, unknown>
recurring_characters: Record<string, unknown>[]
world_settings: Record<string, unknown>
achievements: Record<string, unknown>[]
}
const route = useRoute()
const router = useRouter()
const universe = ref<StoryUniverse | null>(null)
const loading = ref(true)
const error = ref('')
const formError = ref('')
const form = ref({
name: '',
protagonist: '',
recurring_characters: '',
world_settings: '',
})
const achievement = ref({
type: '',
description: '',
})
function toJsonString(value: unknown) {
return JSON.stringify(value ?? {}, null, 2)
}
function parseJson(input: string, label: string) {
try {
return JSON.parse(input)
} catch (e) {
throw new Error(`${label} 需要是合法 JSON`)
}
}
function fillForm(data: StoryUniverse) {
form.value = {
name: data.name,
protagonist: toJsonString(data.protagonist),
recurring_characters: JSON.stringify(data.recurring_characters ?? [], null, 2),
world_settings: toJsonString(data.world_settings),
}
}
async function fetchUniverse() {
loading.value = true
error.value = ''
try {
const data = await api.get<StoryUniverse>(`/api/universes/${route.params.id}`)
universe.value = data
fillForm(data)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function updateUniverse() {
formError.value = ''
if (!form.value.name.trim()) {
formError.value = '名称不能为空'
return
}
try {
const payload = {
name: form.value.name.trim(),
protagonist: parseJson(form.value.protagonist, '主角设定'),
recurring_characters: parseJson(form.value.recurring_characters, '常驻角色'),
world_settings: parseJson(form.value.world_settings, '世界观'),
}
const data = await api.put<StoryUniverse>(`/api/universes/${route.params.id}`, payload)
universe.value = data
fillForm(data)
} catch (e) {
formError.value = e instanceof Error ? e.message : '更新失败'
}
}
async function addAchievement() {
if (!achievement.value.type.trim() || !achievement.value.description.trim()) {
formError.value = '成就类型和描述不能为空'
return
}
try {
const data = await api.post<StoryUniverse>(
`/api/universes/${route.params.id}/achievements`,
{
type: achievement.value.type.trim(),
description: achievement.value.description.trim(),
},
)
universe.value = data
achievement.value = { type: '', description: '' }
} catch (e) {
formError.value = e instanceof Error ? e.message : '添加成就失败'
}
}
async function deleteUniverse() {
if (!window.confirm('确定删除这个宇宙吗?')) return
try {
await api.delete(`/api/universes/${route.params.id}`)
router.push('/universes')
} catch (e) {
formError.value = e instanceof Error ? e.message : '删除失败'
}
}
onMounted(fetchUniverse)
</script>
<template>
<div class="max-w-4xl mx-auto px-4">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">宇宙详情</h1>
<p class="text-gray-500">编辑故事宇宙设定</p>
</div>
<BaseButton as="router-link" to="/universes" variant="ghost" class="text-purple-600">
返回列表
</BaseButton>
</div>
<div v-if="loading" class="py-10">
<LoadingSpinner text="加载中..." />
</div>
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
title="加载失败"
:description="error"
/>
</div>
<div v-else-if="universe" class="space-y-6">
<BaseCard>
<div class="flex items-center gap-2 mb-4">
<GlobeAltIcon class="h-5 w-5 text-purple-500" />
<h2 class="text-lg font-semibold text-gray-700">宇宙设定</h2>
</div>
<div class="grid grid-cols-1 gap-4">
<BaseInput v-model="form.name" placeholder="宇宙名称" />
<BaseTextarea v-model="form.protagonist" :rows="4" placeholder="主角设定 JSON" />
<BaseTextarea v-model="form.recurring_characters" :rows="4" placeholder="常驻角色 JSON" />
<BaseTextarea v-model="form.world_settings" :rows="4" placeholder="世界观 JSON" />
</div>
<div class="mt-4 flex items-center justify-between">
<span v-if="formError" class="text-sm text-red-500">{{ formError }}</span>
<div class="flex gap-3">
<BaseButton @click="updateUniverse">保存</BaseButton>
<BaseButton variant="secondary" class="text-red-500 border-red-200" @click="deleteUniverse">
删除宇宙
</BaseButton>
</div>
</div>
</BaseCard>
<BaseCard>
<div class="flex items-center gap-2 mb-4">
<TrophyIcon class="h-5 w-5 text-purple-500" />
<h2 class="text-lg font-semibold text-gray-700">成就管理</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseInput v-model="achievement.type" placeholder="成就类型" />
<BaseInput v-model="achievement.description" placeholder="成就描述" />
</div>
<div class="mt-4">
<BaseButton @click="addAchievement">添加成就</BaseButton>
</div>
<div class="mt-6">
<div v-if="universe.achievements.length === 0" class="text-gray-500">暂无成就</div>
<ul v-else class="space-y-2">
<li
v-for="(item, index) in universe.achievements"
:key="index"
class="bg-white/70 border border-white/50 rounded-xl px-4 py-3 text-sm text-gray-600"
>
{{ (item as any).type }} · {{ (item as any).description }}
</li>
</ul>
</div>
</BaseCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
import BaseSelect from '../components/ui/BaseSelect.vue'
import BaseTextarea from '../components/ui/BaseTextarea.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { ExclamationCircleIcon, GlobeAltIcon } from '@heroicons/vue/24/outline'
interface ChildProfile {
id: string
name: string
}
interface StoryUniverse {
id: string
name: string
protagonist: Record<string, unknown>
recurring_characters: Record<string, unknown>[]
world_settings: Record<string, unknown>
achievements: Record<string, unknown>[]
}
interface ProfileListResponse {
profiles: ChildProfile[]
total: number
}
interface UniverseListResponse {
universes: StoryUniverse[]
total: number
}
const profiles = ref<ChildProfile[]>([])
const universes = ref<StoryUniverse[]>([])
const selectedProfileId = ref('')
const loading = ref(true)
const error = ref('')
const formError = ref('')
const form = ref({
name: '',
protagonistName: '',
protagonistRole: '',
worldDescription: '',
})
async function fetchProfiles() {
loading.value = true
error.value = ''
try {
const data = await api.get<ProfileListResponse>('/api/profiles')
profiles.value = data.profiles
if (!selectedProfileId.value && profiles.value.length) {
selectedProfileId.value = profiles.value[0].id
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function fetchUniverses(profileId: string) {
if (!profileId) {
universes.value = []
return
}
try {
const data = await api.get<UniverseListResponse>(`/api/profiles/${profileId}/universes`)
universes.value = data.universes
} catch (e) {
error.value = e instanceof Error ? e.message : '加载宇宙失败'
}
}
async function createUniverse() {
formError.value = ''
if (!selectedProfileId.value) {
formError.value = '请先选择孩子档案'
return
}
if (!form.value.name.trim()) {
formError.value = '宇宙名称不能为空'
return
}
if (!form.value.protagonistName.trim()) {
formError.value = '主角姓名不能为空'
return
}
try {
const payload = {
name: form.value.name.trim(),
protagonist: {
name: form.value.protagonistName,
role: form.value.protagonistRole || '探险家'
},
recurring_characters: [],
world_settings: {
description: form.value.worldDescription
},
}
await api.post(`/api/profiles/${selectedProfileId.value}/universes`, payload)
// Reset form
form.value.name = ''
form.value.protagonistName = ''
form.value.protagonistRole = ''
form.value.worldDescription = ''
await fetchUniverses(selectedProfileId.value)
} catch (e) {
formError.value = e instanceof Error ? e.message : '创建失败'
}
}
watch(selectedProfileId, (value) => {
fetchUniverses(value)
})
onMounted(fetchProfiles)
</script>
<template>
<div class="max-w-5xl mx-auto px-4">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">故事宇宙</h1>
<p class="text-gray-500">维护孩子的故事世界观</p>
</div>
</div>
<BaseCard class="mb-8" padding="lg">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseSelect
v-model="selectedProfileId"
:options="profiles.map(profile => ({ value: profile.id, label: profile.name }))"
placeholder="请选择孩子档案"
/>
</div>
</BaseCard>
<BaseCard class="mb-8" padding="lg">
<h2 class="text-lg font-semibold text-gray-700 mb-4">创建新宇宙</h2>
<div class="space-y-4">
<BaseInput v-model="form.name" label="宇宙名称" placeholder="给这个世界起个名字" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseInput v-model="form.protagonistName" label="主角姓名" placeholder="例如:乐乐" />
<BaseInput v-model="form.protagonistRole" label="主角身份" placeholder="例如:小小探险家" />
</div>
<BaseTextarea
v-model="form.worldDescription"
label="世界观描述"
:rows="3"
placeholder="简要描述这个世界的规则,例如:这里的所有动物都会说话,天上有三个月亮..."
/>
</div>
<div class="mt-6 flex items-center justify-between">
<span v-if="formError" class="text-sm text-red-500">{{ formError }}</span>
<div v-else></div> <!-- Spacer -->
<BaseButton @click="createUniverse">创建宇宙</BaseButton>
</div>
</BaseCard>
<div v-if="loading" class="py-10">
<LoadingSpinner text="加载中..." />
</div>
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
title="加载失败"
:description="error"
/>
</div>
<div v-else-if="universes.length === 0" class="py-10">
<EmptyState
:icon="GlobeAltIcon"
title="暂无宇宙"
description="为孩子创建一个新的故事宇宙"
/>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link
v-for="universe in universes"
:key="universe.id"
:to="`/universes/${universe.id}`"
class="block"
>
<BaseCard hover>
<div class="font-semibold text-gray-800">{{ universe.name }}</div>
<div class="text-sm text-gray-500 mt-2">主角{{ (universe.protagonist as any).name || '未设置' }}</div>
<div class="text-sm text-gray-500 mt-1">成就{{ universe.achievements?.length || 0 }} </div>
</BaseCard>
</router-link>
</div>
</div>
</template>