wip: snapshot full local workspace state
Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions

This commit is contained in:
2026-04-17 18:58:11 +08:00
parent fea4ef012f
commit b8d3cb4644
181 changed files with 16964 additions and 17486 deletions

View File

@@ -1,44 +1,48 @@
{
"permissions": {
"allow": [
"Skill(codex)",
"Bash(pip install:*)",
"Bash(alembic upgrade:*)",
"Bash(uvicorn:*)",
"Bash(npm run dev)",
"Bash(python:*)",
"Bash(ruff check:*)",
"Bash(tasklist:*)",
"Bash(findstr:*)",
"Bash(pushd:*)",
"Bash(popd)",
"Bash(curl:*)",
"WebSearch",
"WebFetch(domain:www.novelai.net)",
"WebFetch(domain:www.storywizard.ai)",
"WebFetch(domain:www.oscarstories.com)",
"WebFetch(domain:www.moshi.com)",
"WebFetch(domain:www.calm.com)",
"WebFetch(domain:www.epic.com)",
"WebFetch(domain:www.headspace.com)",
"WebFetch(domain:www.getepic.com)",
"WebFetch(domain:www.tonies.com)",
"Bash(del:*)",
"Bash(netstat:*)",
"Bash(taskkill:*)",
"Bash(codex-wrapper:*)",
"Bash(dir /b /s /a-d /o-d)",
"Bash(dir:*)",
"Bash(.venv/Scripts/python:*)",
"Bash(.venv/Scripts/ruff check:*)",
"Bash(npm run build:*)",
"Bash(test -f \"F:\\\\Code\\\\dreamweaver-python\\\\backend\\\\.env\")",
"Bash(pytest:*)",
"Bash(npm run type-check:*)",
"Bash(npx vue-tsc:*)",
"Bash(ls:*)",
"Bash(git init:*)",
"Bash(git add:*)"
]
}
}
{
"permissions": {
"allow": [
"Skill(codex)",
"Bash(pip install:*)",
"Bash(alembic upgrade:*)",
"Bash(uvicorn:*)",
"Bash(npm run dev)",
"Bash(python:*)",
"Bash(ruff check:*)",
"Bash(tasklist:*)",
"Bash(findstr:*)",
"Bash(pushd:*)",
"Bash(popd)",
"Bash(curl:*)",
"WebSearch",
"WebFetch(domain:www.novelai.net)",
"WebFetch(domain:www.storywizard.ai)",
"WebFetch(domain:www.oscarstories.com)",
"WebFetch(domain:www.moshi.com)",
"WebFetch(domain:www.calm.com)",
"WebFetch(domain:www.epic.com)",
"WebFetch(domain:www.headspace.com)",
"WebFetch(domain:www.getepic.com)",
"WebFetch(domain:www.tonies.com)",
"Bash(del:*)",
"Bash(netstat:*)",
"Bash(taskkill:*)",
"Bash(codex-wrapper:*)",
"Bash(dir /b /s /a-d /o-d)",
"Bash(dir:*)",
"Bash(.venv/Scripts/python:*)",
"Bash(.venv/Scripts/ruff check:*)",
"Bash(npm run build:*)",
"Bash(test -f \"F:\\\\Code\\\\dreamweaver-python\\\\backend\\\\.env\")",
"Bash(pytest:*)",
"Bash(npm run type-check:*)",
"Bash(npx vue-tsc:*)",
"Bash(ls:*)",
"Bash(git init:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git remote add:*)",
"Bash(git push:*)",
"Bash(git branch:*)"
]
}
}

View File

@@ -1,416 +1,416 @@
# 代码架构重构 PRD
> 版本: 1.0
> 日期: 2025-01-21
> 状态: 待实施
---
## 1. 背景与目标
### 1.1 现状问题
经过代码审计,发现以下架构债务:
| 问题 | 位置 | 影响 |
|------|------|------|
| 缺少 Schemas 层 | Backend API | 类型不安全,文档生成差 |
| Fat Controller | `stories.py` 562行 | 难测试,职责混乱 |
| 重复加载逻辑 | Frontend 5+ 组件 | 代码冗余,维护困难 |
| 无 Repository 层 | Backend | DB 查询散落,难复用 |
### 1.2 重构目标
- **后端**:引入 Pydantic Schemas 层,分离请求/响应模型
- **前端**:抽取 Vue Composables消除重复逻辑
- **原则**:渐进式重构,不破坏现有功能
---
## 2. 后端重构Schemas 层
### 2.1 目标结构
```
backend/app/
├── api/ # 路由层(瘦身)
│ ├── stories.py
│ └── ...
├── schemas/ # 新增Pydantic 模型
│ ├── __init__.py
│ ├── auth.py
│ ├── story.py
│ ├── profile.py
│ ├── universe.py
│ ├── memory.py
│ ├── push_config.py
│ └── common.py # 分页、错误响应等通用结构
├── models/ # SQLAlchemy ORM原 db/
└── services/ # 业务逻辑
```
### 2.2 Schema 设计规范
#### 命名约定
| 类型 | 命名模式 | 示例 |
|------|----------|------|
| 创建请求 | `{Entity}Create` | `StoryCreate` |
| 更新请求 | `{Entity}Update` | `ProfileUpdate` |
| 响应模型 | `{Entity}Response` | `StoryResponse` |
| 列表响应 | `{Entity}ListResponse` | `StoryListResponse` |
#### 示例Story Schema
```python
# schemas/story.py
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class StoryCreate(BaseModel):
"""创建故事请求"""
type: Literal["keywords", "enhance", "storybook"]
data: str = Field(..., min_length=1, max_length=2000)
education_theme: str | None = None
child_profile_id: str | None = None
universe_id: str | None = None
class StoryResponse(BaseModel):
"""故事响应"""
id: int
title: str
story_text: str | None
pages: list[dict] | None
image_url: str | None
mode: str
created_at: datetime
class Config:
from_attributes = True # 支持 ORM 模型转换
class StoryListResponse(BaseModel):
"""故事列表响应"""
stories: list[StoryResponse]
total: int
page: int
page_size: int
```
### 2.3 迁移步骤
| 阶段 | 任务 | 文件 |
|------|------|------|
| P1 | 创建 `schemas/common.py` | 分页、错误响应 |
| P2 | 创建 `schemas/story.py` | 故事相关模型 |
| P3 | 创建 `schemas/profile.py` | 档案相关模型 |
| P4 | 创建 `schemas/universe.py` | 宇宙相关模型 |
| P5 | 创建 `schemas/auth.py` | 认证相关模型 |
| P6 | 创建 `schemas/memory.py` | 记忆相关模型 |
| P7 | 创建 `schemas/push_config.py` | 推送配置模型 |
| P8 | 重构 `api/stories.py` | 使用新 schemas |
| P9 | 重构其他 API 文件 | 逐个迁移 |
### 2.4 验收标准
- [ ] 所有 API endpoint 使用 `response_model` 参数
- [ ] 请求体使用 Pydantic 模型而非 `Body(...)`
- [ ] OpenAPI 文档自动生成完整类型
- [ ] 现有测试全部通过
- [ ] `ruff check` 无错误
---
## 3. 前端重构Composables
### 3.1 目标结构
```
frontend/src/
├── composables/ # 新增:可复用逻辑
│ ├── index.ts
│ ├── useAsyncData.ts # 异步数据加载
│ ├── useFormValidation.ts # 表单验证
│ └── useDateFormat.ts # 日期格式化
├── components/
├── stores/
└── views/
```
### 3.2 Composable 设计
#### 3.2.1 useAsyncData
**用途**:统一处理 API 数据加载的 loading/error 状态
```typescript
// composables/useAsyncData.ts
import { ref, type Ref } from 'vue'
interface AsyncDataOptions<T> {
immediate?: boolean // 立即执行,默认 true
initialData?: T // 初始数据
onError?: (e: Error) => void
}
interface AsyncDataReturn<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<string>
execute: () => Promise<void>
reset: () => void
}
export function useAsyncData<T>(
fetcher: () => Promise<T>,
options: AsyncDataOptions<T> = {}
): AsyncDataReturn<T> {
const { immediate = true, initialData = null, onError } = options
const data = ref<T | null>(initialData) as Ref<T | null>
const loading = ref(false)
const error = ref('')
async function execute() {
loading.value = true
error.value = ''
try {
data.value = await fetcher()
} catch (e) {
const message = e instanceof Error ? e.message : '加载失败'
error.value = message
onError?.(e instanceof Error ? e : new Error(message))
} finally {
loading.value = false
}
}
function reset() {
data.value = initialData
loading.value = false
error.value = ''
}
if (immediate) {
execute()
}
return { data, loading, error, execute, reset }
}
```
**使用示例**
```typescript
// views/MyStories.vue重构前 40 行 → 重构后 10 行)
import { useAsyncData } from '@/composables'
import { api } from '@/api/client'
const { data: response, loading, error } = useAsyncData(
() => api.get<StoryListResponse>('/api/stories')
)
const stories = computed(() => response.value?.stories ?? [])
```
#### 3.2.2 useFormValidation
**用途**:统一表单验证逻辑
```typescript
// composables/useFormValidation.ts
import { ref, reactive } from 'vue'
type ValidationRule = (value: unknown) => string | true
interface FieldRules {
[field: string]: ValidationRule[]
}
export function useFormValidation<T extends Record<string, unknown>>(
initialData: T,
rules: FieldRules
) {
const form = reactive({ ...initialData })
const errors = reactive<Record<string, string>>({})
function validate(): boolean {
let valid = true
for (const [field, fieldRules] of Object.entries(rules)) {
const value = form[field]
for (const rule of fieldRules) {
const result = rule(value)
if (result !== true) {
errors[field] = result
valid = false
break
} else {
errors[field] = ''
}
}
}
return valid
}
function reset() {
Object.assign(form, initialData)
Object.keys(errors).forEach(k => errors[k] = '')
}
return { form, errors, validate, reset }
}
// 常用验证规则
export const required = (msg = '此字段必填') =>
(v: unknown) => (v !== null && v !== undefined && v !== '') || msg
export const minLength = (min: number, msg?: string) =>
(v: unknown) => (typeof v === 'string' && v.length >= min) || msg || `至少 ${min} 个字符`
export const maxLength = (max: number, msg?: string) =>
(v: unknown) => (typeof v === 'string' && v.length <= max) || msg || `最多 ${max} 个字符`
```
#### 3.2.3 useDateFormat
**用途**:统一日期格式化
```typescript
// composables/useDateFormat.ts
export function useDateFormat() {
function formatDate(dateStr: string | Date, format: 'date' | 'datetime' | 'relative' = 'date'): string {
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
if (format === 'relative') {
return formatRelative(date)
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
if (format === 'date') {
return `${year}-${month}-${day}`
}
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function formatRelative(date: Date): string {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins} 分钟前`
if (diffHours < 24) return `${diffHours} 小时前`
if (diffDays < 7) return `${diffDays} 天前`
return formatDate(date, 'date')
}
return { formatDate, formatRelative }
}
```
### 3.3 迁移步骤
| 阶段 | 任务 | 影响文件 |
|------|------|----------|
| P1 | 创建 `composables/useAsyncData.ts` | 新文件 |
| P2 | 创建 `composables/useDateFormat.ts` | 新文件 |
| P3 | 创建 `composables/useFormValidation.ts` | 新文件 |
| P4 | 创建 `composables/index.ts` | 统一导出 |
| P5 | 重构 `views/MyStories.vue` | 使用 useAsyncData |
| P6 | 重构 `views/ChildProfiles.vue` | 使用 useAsyncData |
| P7 | 重构 `views/Universes.vue` | 使用 useAsyncData |
| P8 | 重构 `components/CreateStoryModal.vue` | 使用 useFormValidation |
| P9 | 同步重构 `admin-frontend/` | 复制 composables |
### 3.4 验收标准
- [ ] `composables/` 目录包含 3 个核心 composable
- [ ] 至少 3 个 view 组件使用 `useAsyncData`
- [ ] `CreateStoryModal` 使用 `useFormValidation`
- [ ] `npm run build` 无类型错误
- [ ] 现有功能正常运行
---
## 4. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 重构引入 bug | 高 | 每个阶段运行测试 |
| 类型不兼容 | 中 | 使用 `from_attributes = True` |
| 前端构建失败 | 中 | 逐文件迁移,及时 commit |
---
## 5. 时间估算
| 模块 | 工作量 | 预计时间 |
|------|--------|----------|
| Backend Schemas | 10 个新文件 + 9 个 API 重构 | 3-4 小时 |
| Frontend Composables | 4 个新文件 + 6 个组件重构 | 2-3 小时 |
| 测试验证 | 全量回归 | 1 小时 |
| **总计** | | **6-8 小时** |
---
## 6. 后续迭代
本次重构完成后,可继续:
1. **Repository 层**:抽象 DB 查询逻辑
2. **Service 层瘦身**:拆分 `provider_router.py` (433行)
3. **前端共享包**`frontend``admin-frontend` 共用 UI 组件
4. **API 版本化**:引入 `/api/v1/` 前缀
---
## 附录:文件清单
### 新增文件
```
backend/app/schemas/
├── __init__.py
├── common.py
├── auth.py
├── story.py
├── profile.py
├── universe.py
├── memory.py
└── push_config.py
frontend/src/composables/
├── index.ts
├── useAsyncData.ts
├── useFormValidation.ts
└── useDateFormat.ts
```
### 修改文件
```
backend/app/api/
├── stories.py
├── profiles.py
├── universes.py
├── memories.py
├── push_configs.py
├── reading_events.py
└── auth.py
frontend/src/views/
├── MyStories.vue
├── ChildProfiles.vue
├── Universes.vue
└── ...
frontend/src/components/
└── CreateStoryModal.vue
```
# 代码架构重构 PRD
> 版本: 1.0
> 日期: 2025-01-21
> 状态: 待实施
---
## 1. 背景与目标
### 1.1 现状问题
经过代码审计,发现以下架构债务:
| 问题 | 位置 | 影响 |
|------|------|------|
| 缺少 Schemas 层 | Backend API | 类型不安全,文档生成差 |
| Fat Controller | `stories.py` 562行 | 难测试,职责混乱 |
| 重复加载逻辑 | Frontend 5+ 组件 | 代码冗余,维护困难 |
| 无 Repository 层 | Backend | DB 查询散落,难复用 |
### 1.2 重构目标
- **后端**:引入 Pydantic Schemas 层,分离请求/响应模型
- **前端**:抽取 Vue Composables消除重复逻辑
- **原则**:渐进式重构,不破坏现有功能
---
## 2. 后端重构Schemas 层
### 2.1 目标结构
```
backend/app/
├── api/ # 路由层(瘦身)
│ ├── stories.py
│ └── ...
├── schemas/ # 新增Pydantic 模型
│ ├── __init__.py
│ ├── auth.py
│ ├── story.py
│ ├── profile.py
│ ├── universe.py
│ ├── memory.py
│ ├── push_config.py
│ └── common.py # 分页、错误响应等通用结构
├── models/ # SQLAlchemy ORM原 db/
└── services/ # 业务逻辑
```
### 2.2 Schema 设计规范
#### 命名约定
| 类型 | 命名模式 | 示例 |
|------|----------|------|
| 创建请求 | `{Entity}Create` | `StoryCreate` |
| 更新请求 | `{Entity}Update` | `ProfileUpdate` |
| 响应模型 | `{Entity}Response` | `StoryResponse` |
| 列表响应 | `{Entity}ListResponse` | `StoryListResponse` |
#### 示例Story Schema
```python
# schemas/story.py
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class StoryCreate(BaseModel):
"""创建故事请求"""
type: Literal["keywords", "enhance", "storybook"]
data: str = Field(..., min_length=1, max_length=2000)
education_theme: str | None = None
child_profile_id: str | None = None
universe_id: str | None = None
class StoryResponse(BaseModel):
"""故事响应"""
id: int
title: str
story_text: str | None
pages: list[dict] | None
image_url: str | None
mode: str
created_at: datetime
class Config:
from_attributes = True # 支持 ORM 模型转换
class StoryListResponse(BaseModel):
"""故事列表响应"""
stories: list[StoryResponse]
total: int
page: int
page_size: int
```
### 2.3 迁移步骤
| 阶段 | 任务 | 文件 |
|------|------|------|
| P1 | 创建 `schemas/common.py` | 分页、错误响应 |
| P2 | 创建 `schemas/story.py` | 故事相关模型 |
| P3 | 创建 `schemas/profile.py` | 档案相关模型 |
| P4 | 创建 `schemas/universe.py` | 宇宙相关模型 |
| P5 | 创建 `schemas/auth.py` | 认证相关模型 |
| P6 | 创建 `schemas/memory.py` | 记忆相关模型 |
| P7 | 创建 `schemas/push_config.py` | 推送配置模型 |
| P8 | 重构 `api/stories.py` | 使用新 schemas |
| P9 | 重构其他 API 文件 | 逐个迁移 |
### 2.4 验收标准
- [ ] 所有 API endpoint 使用 `response_model` 参数
- [ ] 请求体使用 Pydantic 模型而非 `Body(...)`
- [ ] OpenAPI 文档自动生成完整类型
- [ ] 现有测试全部通过
- [ ] `ruff check` 无错误
---
## 3. 前端重构Composables
### 3.1 目标结构
```
frontend/src/
├── composables/ # 新增:可复用逻辑
│ ├── index.ts
│ ├── useAsyncData.ts # 异步数据加载
│ ├── useFormValidation.ts # 表单验证
│ └── useDateFormat.ts # 日期格式化
├── components/
├── stores/
└── views/
```
### 3.2 Composable 设计
#### 3.2.1 useAsyncData
**用途**:统一处理 API 数据加载的 loading/error 状态
```typescript
// composables/useAsyncData.ts
import { ref, type Ref } from 'vue'
interface AsyncDataOptions<T> {
immediate?: boolean // 立即执行,默认 true
initialData?: T // 初始数据
onError?: (e: Error) => void
}
interface AsyncDataReturn<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<string>
execute: () => Promise<void>
reset: () => void
}
export function useAsyncData<T>(
fetcher: () => Promise<T>,
options: AsyncDataOptions<T> = {}
): AsyncDataReturn<T> {
const { immediate = true, initialData = null, onError } = options
const data = ref<T | null>(initialData) as Ref<T | null>
const loading = ref(false)
const error = ref('')
async function execute() {
loading.value = true
error.value = ''
try {
data.value = await fetcher()
} catch (e) {
const message = e instanceof Error ? e.message : '加载失败'
error.value = message
onError?.(e instanceof Error ? e : new Error(message))
} finally {
loading.value = false
}
}
function reset() {
data.value = initialData
loading.value = false
error.value = ''
}
if (immediate) {
execute()
}
return { data, loading, error, execute, reset }
}
```
**使用示例**
```typescript
// views/MyStories.vue重构前 40 行 → 重构后 10 行)
import { useAsyncData } from '@/composables'
import { api } from '@/api/client'
const { data: response, loading, error } = useAsyncData(
() => api.get<StoryListResponse>('/api/stories')
)
const stories = computed(() => response.value?.stories ?? [])
```
#### 3.2.2 useFormValidation
**用途**:统一表单验证逻辑
```typescript
// composables/useFormValidation.ts
import { ref, reactive } from 'vue'
type ValidationRule = (value: unknown) => string | true
interface FieldRules {
[field: string]: ValidationRule[]
}
export function useFormValidation<T extends Record<string, unknown>>(
initialData: T,
rules: FieldRules
) {
const form = reactive({ ...initialData })
const errors = reactive<Record<string, string>>({})
function validate(): boolean {
let valid = true
for (const [field, fieldRules] of Object.entries(rules)) {
const value = form[field]
for (const rule of fieldRules) {
const result = rule(value)
if (result !== true) {
errors[field] = result
valid = false
break
} else {
errors[field] = ''
}
}
}
return valid
}
function reset() {
Object.assign(form, initialData)
Object.keys(errors).forEach(k => errors[k] = '')
}
return { form, errors, validate, reset }
}
// 常用验证规则
export const required = (msg = '此字段必填') =>
(v: unknown) => (v !== null && v !== undefined && v !== '') || msg
export const minLength = (min: number, msg?: string) =>
(v: unknown) => (typeof v === 'string' && v.length >= min) || msg || `至少 ${min} 个字符`
export const maxLength = (max: number, msg?: string) =>
(v: unknown) => (typeof v === 'string' && v.length <= max) || msg || `最多 ${max} 个字符`
```
#### 3.2.3 useDateFormat
**用途**:统一日期格式化
```typescript
// composables/useDateFormat.ts
export function useDateFormat() {
function formatDate(dateStr: string | Date, format: 'date' | 'datetime' | 'relative' = 'date'): string {
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
if (format === 'relative') {
return formatRelative(date)
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
if (format === 'date') {
return `${year}-${month}-${day}`
}
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function formatRelative(date: Date): string {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins} 分钟前`
if (diffHours < 24) return `${diffHours} 小时前`
if (diffDays < 7) return `${diffDays} 天前`
return formatDate(date, 'date')
}
return { formatDate, formatRelative }
}
```
### 3.3 迁移步骤
| 阶段 | 任务 | 影响文件 |
|------|------|----------|
| P1 | 创建 `composables/useAsyncData.ts` | 新文件 |
| P2 | 创建 `composables/useDateFormat.ts` | 新文件 |
| P3 | 创建 `composables/useFormValidation.ts` | 新文件 |
| P4 | 创建 `composables/index.ts` | 统一导出 |
| P5 | 重构 `views/MyStories.vue` | 使用 useAsyncData |
| P6 | 重构 `views/ChildProfiles.vue` | 使用 useAsyncData |
| P7 | 重构 `views/Universes.vue` | 使用 useAsyncData |
| P8 | 重构 `components/CreateStoryModal.vue` | 使用 useFormValidation |
| P9 | 同步重构 `admin-frontend/` | 复制 composables |
### 3.4 验收标准
- [ ] `composables/` 目录包含 3 个核心 composable
- [ ] 至少 3 个 view 组件使用 `useAsyncData`
- [ ] `CreateStoryModal` 使用 `useFormValidation`
- [ ] `npm run build` 无类型错误
- [ ] 现有功能正常运行
---
## 4. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 重构引入 bug | 高 | 每个阶段运行测试 |
| 类型不兼容 | 中 | 使用 `from_attributes = True` |
| 前端构建失败 | 中 | 逐文件迁移,及时 commit |
---
## 5. 时间估算
| 模块 | 工作量 | 预计时间 |
|------|--------|----------|
| Backend Schemas | 10 个新文件 + 9 个 API 重构 | 3-4 小时 |
| Frontend Composables | 4 个新文件 + 6 个组件重构 | 2-3 小时 |
| 测试验证 | 全量回归 | 1 小时 |
| **总计** | | **6-8 小时** |
---
## 6. 后续迭代
本次重构完成后,可继续:
1. **Repository 层**:抽象 DB 查询逻辑
2. **Service 层瘦身**:拆分 `provider_router.py` (433行)
3. **前端共享包**`frontend``admin-frontend` 共用 UI 组件
4. **API 版本化**:引入 `/api/v1/` 前缀
---
## 附录:文件清单
### 新增文件
```
backend/app/schemas/
├── __init__.py
├── common.py
├── auth.py
├── story.py
├── profile.py
├── universe.py
├── memory.py
└── push_config.py
frontend/src/composables/
├── index.ts
├── useAsyncData.ts
├── useFormValidation.ts
└── useDateFormat.ts
```
### 修改文件
```
backend/app/api/
├── stories.py
├── profiles.py
├── universes.py
├── memories.py
├── push_configs.py
├── reading_events.py
└── auth.py
frontend/src/views/
├── MyStories.vue
├── ChildProfiles.vue
├── Universes.vue
└── ...
frontend/src/components/
└── CreateStoryModal.vue
```

View File

@@ -150,4 +150,4 @@
## 推荐
建议 Web MVP 使用方案 ASoft Aurora兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。
建议 Web MVP 使用方案 ASoft Aurora兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。

File diff suppressed because it is too large Load Diff

View File

@@ -227,4 +227,4 @@
- 组件做 Variant
- 全部使用 Auto Layout
- 1440/1200/1024/768 建立栅格
- 状态页复制并标注
- 状态页复制并标注

View File

@@ -296,4 +296,4 @@
- 设计系统库(色板、文字、组件)
- 全流程高保真页面
- 原型链接Figma 中生成)
- 原型链接Figma 中生成)

View File

@@ -56,4 +56,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -71,4 +71,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -85,4 +85,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -55,4 +55,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -108,4 +108,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -30,4 +30,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -25,4 +25,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -81,4 +81,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -17,4 +17,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -75,4 +75,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -70,4 +70,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -214,4 +214,4 @@ a { color: var(--primary-600); text-decoration: none; }
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.row { grid-template-columns: 1fr; }
}
}

View File

@@ -61,4 +61,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -61,4 +61,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -56,4 +56,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -71,4 +71,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -85,4 +85,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -55,4 +55,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -108,4 +108,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -30,4 +30,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -25,4 +25,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -81,4 +81,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -17,4 +17,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -75,4 +75,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -70,4 +70,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -214,4 +214,4 @@ a { color: var(--primary-600); text-decoration: none; }
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.row { grid-template-columns: 1fr; }
}
}

View File

@@ -61,4 +61,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -61,4 +61,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -56,4 +56,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -71,4 +71,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -85,4 +85,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -55,4 +55,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -108,4 +108,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -30,4 +30,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -25,4 +25,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -81,4 +81,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -17,4 +17,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -75,4 +75,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -70,4 +70,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -214,4 +214,4 @@ a { color: var(--primary-600); text-decoration: none; }
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.row { grid-template-columns: 1fr; }
}
}

View File

@@ -61,4 +61,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -61,4 +61,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@@ -1,429 +1,429 @@
# 孩子档案数据模型
## 概述
孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。
---
## 一、数据库模型
### 1.1 主表: child_profiles
```sql
CREATE TABLE child_profiles (
-- 主键
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 外键: 所属用户
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 基础信息
name VARCHAR(50) NOT NULL,
avatar_url VARCHAR(500),
birth_date DATE,
gender VARCHAR(10) CHECK (gender IN ('male', 'female', 'other')),
-- 显式偏好 (家长填写)
interests JSONB DEFAULT '[]',
-- 示例: ["恐龙", "太空", "公主", "动物"]
growth_themes JSONB DEFAULT '[]',
-- 示例: ["勇气", "分享"]
-- 隐式偏好 (系统学习)
reading_preferences JSONB DEFAULT '{}',
-- 示例: {
-- "preferred_length": "medium", -- short/medium/long
-- "preferred_style": "adventure", -- adventure/fairy/educational
-- "tag_weights": {"恐龙": 5, "公主": 2, "太空": 3}
-- }
-- 统计数据
stories_count INTEGER DEFAULT 0,
total_reading_time INTEGER DEFAULT 0, -- 秒
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- 约束
CONSTRAINT unique_child_per_user UNIQUE (user_id, name)
);
-- 索引
CREATE INDEX idx_child_profiles_user_id ON child_profiles(user_id);
```
### 1.2 兴趣标签枚举
预定义的兴趣标签,前端展示用:
```python
INTEREST_TAGS = {
"animals": {
"zh": "动物",
"icon": "🐾",
"subtags": ["恐龙", "猫咪", "狗狗", "兔子", "海洋动物"]
},
"fantasy": {
"zh": "奇幻",
"icon": "",
"subtags": ["公主", "王子", "魔法", "精灵", ""]
},
"adventure": {
"zh": "冒险",
"icon": "🗺️",
"subtags": ["太空", "海盗", "探险", "寻宝"]
},
"vehicles": {
"zh": "交通工具",
"icon": "🚗",
"subtags": ["汽车", "火车", "飞机", "火箭"]
},
"nature": {
"zh": "自然",
"icon": "🌳",
"subtags": ["森林", "海洋", "山川", "四季"]
}
}
```
### 1.3 成长主题枚举
```python
GROWTH_THEMES = [
{"key": "courage", "zh": "勇气", "description": "克服恐惧,勇敢面对"},
{"key": "sharing", "zh": "分享", "description": "学会与他人分享"},
{"key": "friendship", "zh": "友谊", "description": "交朋友,珍惜友情"},
{"key": "honesty", "zh": "诚实", "description": "说真话,不撒谎"},
{"key": "independence", "zh": "独立", "description": "自己的事情自己做"},
{"key": "kindness", "zh": "善良", "description": "帮助他人,关爱弱小"},
{"key": "patience", "zh": "耐心", "description": "学会等待,不急躁"},
{"key": "curiosity", "zh": "好奇", "description": "探索未知,爱问为什么"}
]
```
---
## 二、SQLAlchemy 模型
```python
# backend/app/db/models.py
from sqlalchemy import Column, String, Date, Integer, ForeignKey, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
import uuid
class ChildProfile(Base):
__tablename__ = "child_profiles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 基础信息
name = Column(String(50), nullable=False)
avatar_url = Column(String(500))
birth_date = Column(Date)
gender = Column(String(10))
# 偏好
interests = Column(JSON, default=list)
growth_themes = Column(JSON, default=list)
reading_preferences = Column(JSON, default=dict)
# 统计
stories_count = Column(Integer, default=0)
total_reading_time = Column(Integer, default=0)
# 时间戳
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# 关系
user = relationship("User", back_populates="child_profiles")
story_universes = relationship("StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan")
@property
def age(self) -> int | None:
"""计算年龄"""
if not self.birth_date:
return None
today = date.today()
return today.year - self.birth_date.year - (
(today.month, today.day) < (self.birth_date.month, self.birth_date.day)
)
```
---
## 三、Pydantic Schema
```python
# backend/app/schemas/child_profile.py
from pydantic import BaseModel, Field
from datetime import date
from uuid import UUID
class ChildProfileCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
birth_date: date | None = None
gender: str | None = Field(None, pattern="^(male|female|other)$")
interests: list[str] = Field(default_factory=list)
growth_themes: list[str] = Field(default_factory=list)
class ChildProfileUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=50)
birth_date: date | None = None
gender: str | None = Field(None, pattern="^(male|female|other)$")
interests: list[str] | None = None
growth_themes: list[str] | None = None
avatar_url: str | None = None
class ChildProfileResponse(BaseModel):
id: UUID
name: str
avatar_url: str | None
birth_date: date | None
gender: str | None
age: int | None
interests: list[str]
growth_themes: list[str]
stories_count: int
total_reading_time: int
class Config:
from_attributes = True
class ChildProfileListResponse(BaseModel):
profiles: list[ChildProfileResponse]
total: int
```
---
## 四、API 实现
```python
# backend/app/api/profiles.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
router = APIRouter(prefix="/api/profiles", tags=["profiles"])
@router.get("", response_model=ChildProfileListResponse)
async def list_profiles(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取当前用户的所有孩子档案"""
profiles = await db.execute(
select(ChildProfile)
.where(ChildProfile.user_id == current_user.id)
.order_by(ChildProfile.created_at)
)
profiles = profiles.scalars().all()
return ChildProfileListResponse(profiles=profiles, total=len(profiles))
@router.post("", response_model=ChildProfileResponse, status_code=201)
async def create_profile(
data: ChildProfileCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""创建孩子档案"""
# 检查是否超过限制 (每用户最多5个孩子档案)
count = await db.scalar(
select(func.count(ChildProfile.id))
.where(ChildProfile.user_id == current_user.id)
)
if count >= 5:
raise HTTPException(400, "最多只能创建5个孩子档案")
profile = ChildProfile(user_id=current_user.id, **data.model_dump())
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile
@router.get("/{profile_id}", response_model=ChildProfileResponse)
async def get_profile(
profile_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取单个孩子档案"""
profile = await db.get(ChildProfile, profile_id)
if not profile or profile.user_id != current_user.id:
raise HTTPException(404, "档案不存在")
return profile
@router.put("/{profile_id}", response_model=ChildProfileResponse)
async def update_profile(
profile_id: UUID,
data: ChildProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""更新孩子档案"""
profile = await db.get(ChildProfile, profile_id)
if not profile or profile.user_id != current_user.id:
raise HTTPException(404, "档案不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(profile, key, value)
await db.commit()
await db.refresh(profile)
return profile
@router.delete("/{profile_id}", status_code=204)
async def delete_profile(
profile_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""删除孩子档案"""
profile = await db.get(ChildProfile, profile_id)
if not profile or profile.user_id != current_user.id:
raise HTTPException(404, "档案不存在")
await db.delete(profile)
await db.commit()
```
---
## 五、隐式偏好学习
### 5.1 行为事件
```python
class ReadingEvent(BaseModel):
"""阅读行为事件"""
profile_id: UUID
story_id: UUID
event_type: Literal["started", "completed", "skipped", "replayed"]
reading_time: int # 秒
timestamp: datetime
```
### 5.2 偏好更新算法
```python
async def update_reading_preferences(
db: AsyncSession,
profile_id: UUID,
story: Story,
event: ReadingEvent
):
"""根据阅读行为更新隐式偏好"""
profile = await db.get(ChildProfile, profile_id)
prefs = profile.reading_preferences or {}
tag_weights = prefs.get("tag_weights", {})
# 权重调整
weight_delta = {
"completed": 1.0, # 完整阅读,正向
"replayed": 1.5, # 重复播放,强正向
"skipped": -0.5, # 跳过,负向
"started": 0.1 # 开始阅读,弱正向
}
delta = weight_delta.get(event.event_type, 0)
for tag in story.tags:
current = tag_weights.get(tag, 0)
tag_weights[tag] = max(0, current + delta) # 不低于0
# 更新阅读长度偏好
if event.event_type == "completed":
word_count = len(story.content)
if word_count < 300:
length_pref = "short"
elif word_count < 600:
length_pref = "medium"
else:
length_pref = "long"
# 简单的移动平均
prefs["preferred_length"] = length_pref
prefs["tag_weights"] = tag_weights
profile.reading_preferences = prefs
await db.commit()
```
---
## 六、数据迁移
```python
# backend/alembic/versions/xxx_add_child_profiles.py
def upgrade():
op.create_table(
'child_profiles',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(50), nullable=False),
sa.Column('avatar_url', sa.String(500)),
sa.Column('birth_date', sa.Date()),
sa.Column('gender', sa.String(10)),
sa.Column('interests', sa.JSON(), server_default='[]'),
sa.Column('growth_themes', sa.JSON(), server_default='[]'),
sa.Column('reading_preferences', sa.JSON(), server_default='{}'),
sa.Column('stories_count', sa.Integer(), server_default='0'),
sa.Column('total_reading_time', sa.Integer(), server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_child_profiles_user_id', 'child_profiles', ['user_id'])
def downgrade():
op.drop_index('idx_child_profiles_user_id')
op.drop_table('child_profiles')
```
---
## 七、隐私与安全
### 7.1 数据加密
敏感字段(姓名、出生日期)在存储时加密:
```python
from cryptography.fernet import Fernet
class EncryptedChildProfile:
"""加密存储的孩子档案"""
@staticmethod
def encrypt_name(name: str, key: bytes) -> str:
f = Fernet(key)
return f.encrypt(name.encode()).decode()
@staticmethod
def decrypt_name(encrypted: str, key: bytes) -> str:
f = Fernet(key)
return f.decrypt(encrypted.encode()).decode()
```
### 7.2 访问控制
- 孩子档案只能被创建者访问
- 删除用户时级联删除所有孩子档案
- API 层强制校验 `user_id` 归属
### 7.3 数据保留
- 用户可随时删除孩子档案
- 删除后 30 天内可恢复(软删除)
- 30 天后永久删除
# 孩子档案数据模型
## 概述
孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。
---
## 一、数据库模型
### 1.1 主表: child_profiles
```sql
CREATE TABLE child_profiles (
-- 主键
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 外键: 所属用户
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 基础信息
name VARCHAR(50) NOT NULL,
avatar_url VARCHAR(500),
birth_date DATE,
gender VARCHAR(10) CHECK (gender IN ('male', 'female', 'other')),
-- 显式偏好 (家长填写)
interests JSONB DEFAULT '[]',
-- 示例: ["恐龙", "太空", "公主", "动物"]
growth_themes JSONB DEFAULT '[]',
-- 示例: ["勇气", "分享"]
-- 隐式偏好 (系统学习)
reading_preferences JSONB DEFAULT '{}',
-- 示例: {
-- "preferred_length": "medium", -- short/medium/long
-- "preferred_style": "adventure", -- adventure/fairy/educational
-- "tag_weights": {"恐龙": 5, "公主": 2, "太空": 3}
-- }
-- 统计数据
stories_count INTEGER DEFAULT 0,
total_reading_time INTEGER DEFAULT 0, -- 秒
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- 约束
CONSTRAINT unique_child_per_user UNIQUE (user_id, name)
);
-- 索引
CREATE INDEX idx_child_profiles_user_id ON child_profiles(user_id);
```
### 1.2 兴趣标签枚举
预定义的兴趣标签,前端展示用:
```python
INTEREST_TAGS = {
"animals": {
"zh": "动物",
"icon": "🐾",
"subtags": ["恐龙", "猫咪", "狗狗", "兔子", "海洋动物"]
},
"fantasy": {
"zh": "奇幻",
"icon": "",
"subtags": ["公主", "王子", "魔法", "精灵", ""]
},
"adventure": {
"zh": "冒险",
"icon": "🗺️",
"subtags": ["太空", "海盗", "探险", "寻宝"]
},
"vehicles": {
"zh": "交通工具",
"icon": "🚗",
"subtags": ["汽车", "火车", "飞机", "火箭"]
},
"nature": {
"zh": "自然",
"icon": "🌳",
"subtags": ["森林", "海洋", "山川", "四季"]
}
}
```
### 1.3 成长主题枚举
```python
GROWTH_THEMES = [
{"key": "courage", "zh": "勇气", "description": "克服恐惧,勇敢面对"},
{"key": "sharing", "zh": "分享", "description": "学会与他人分享"},
{"key": "friendship", "zh": "友谊", "description": "交朋友,珍惜友情"},
{"key": "honesty", "zh": "诚实", "description": "说真话,不撒谎"},
{"key": "independence", "zh": "独立", "description": "自己的事情自己做"},
{"key": "kindness", "zh": "善良", "description": "帮助他人,关爱弱小"},
{"key": "patience", "zh": "耐心", "description": "学会等待,不急躁"},
{"key": "curiosity", "zh": "好奇", "description": "探索未知,爱问为什么"}
]
```
---
## 二、SQLAlchemy 模型
```python
# backend/app/db/models.py
from sqlalchemy import Column, String, Date, Integer, ForeignKey, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
import uuid
class ChildProfile(Base):
__tablename__ = "child_profiles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 基础信息
name = Column(String(50), nullable=False)
avatar_url = Column(String(500))
birth_date = Column(Date)
gender = Column(String(10))
# 偏好
interests = Column(JSON, default=list)
growth_themes = Column(JSON, default=list)
reading_preferences = Column(JSON, default=dict)
# 统计
stories_count = Column(Integer, default=0)
total_reading_time = Column(Integer, default=0)
# 时间戳
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# 关系
user = relationship("User", back_populates="child_profiles")
story_universes = relationship("StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan")
@property
def age(self) -> int | None:
"""计算年龄"""
if not self.birth_date:
return None
today = date.today()
return today.year - self.birth_date.year - (
(today.month, today.day) < (self.birth_date.month, self.birth_date.day)
)
```
---
## 三、Pydantic Schema
```python
# backend/app/schemas/child_profile.py
from pydantic import BaseModel, Field
from datetime import date
from uuid import UUID
class ChildProfileCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
birth_date: date | None = None
gender: str | None = Field(None, pattern="^(male|female|other)$")
interests: list[str] = Field(default_factory=list)
growth_themes: list[str] = Field(default_factory=list)
class ChildProfileUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=50)
birth_date: date | None = None
gender: str | None = Field(None, pattern="^(male|female|other)$")
interests: list[str] | None = None
growth_themes: list[str] | None = None
avatar_url: str | None = None
class ChildProfileResponse(BaseModel):
id: UUID
name: str
avatar_url: str | None
birth_date: date | None
gender: str | None
age: int | None
interests: list[str]
growth_themes: list[str]
stories_count: int
total_reading_time: int
class Config:
from_attributes = True
class ChildProfileListResponse(BaseModel):
profiles: list[ChildProfileResponse]
total: int
```
---
## 四、API 实现
```python
# backend/app/api/profiles.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
router = APIRouter(prefix="/api/profiles", tags=["profiles"])
@router.get("", response_model=ChildProfileListResponse)
async def list_profiles(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取当前用户的所有孩子档案"""
profiles = await db.execute(
select(ChildProfile)
.where(ChildProfile.user_id == current_user.id)
.order_by(ChildProfile.created_at)
)
profiles = profiles.scalars().all()
return ChildProfileListResponse(profiles=profiles, total=len(profiles))
@router.post("", response_model=ChildProfileResponse, status_code=201)
async def create_profile(
data: ChildProfileCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""创建孩子档案"""
# 检查是否超过限制 (每用户最多5个孩子档案)
count = await db.scalar(
select(func.count(ChildProfile.id))
.where(ChildProfile.user_id == current_user.id)
)
if count >= 5:
raise HTTPException(400, "最多只能创建5个孩子档案")
profile = ChildProfile(user_id=current_user.id, **data.model_dump())
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile
@router.get("/{profile_id}", response_model=ChildProfileResponse)
async def get_profile(
profile_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取单个孩子档案"""
profile = await db.get(ChildProfile, profile_id)
if not profile or profile.user_id != current_user.id:
raise HTTPException(404, "档案不存在")
return profile
@router.put("/{profile_id}", response_model=ChildProfileResponse)
async def update_profile(
profile_id: UUID,
data: ChildProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""更新孩子档案"""
profile = await db.get(ChildProfile, profile_id)
if not profile or profile.user_id != current_user.id:
raise HTTPException(404, "档案不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(profile, key, value)
await db.commit()
await db.refresh(profile)
return profile
@router.delete("/{profile_id}", status_code=204)
async def delete_profile(
profile_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""删除孩子档案"""
profile = await db.get(ChildProfile, profile_id)
if not profile or profile.user_id != current_user.id:
raise HTTPException(404, "档案不存在")
await db.delete(profile)
await db.commit()
```
---
## 五、隐式偏好学习
### 5.1 行为事件
```python
class ReadingEvent(BaseModel):
"""阅读行为事件"""
profile_id: UUID
story_id: UUID
event_type: Literal["started", "completed", "skipped", "replayed"]
reading_time: int # 秒
timestamp: datetime
```
### 5.2 偏好更新算法
```python
async def update_reading_preferences(
db: AsyncSession,
profile_id: UUID,
story: Story,
event: ReadingEvent
):
"""根据阅读行为更新隐式偏好"""
profile = await db.get(ChildProfile, profile_id)
prefs = profile.reading_preferences or {}
tag_weights = prefs.get("tag_weights", {})
# 权重调整
weight_delta = {
"completed": 1.0, # 完整阅读,正向
"replayed": 1.5, # 重复播放,强正向
"skipped": -0.5, # 跳过,负向
"started": 0.1 # 开始阅读,弱正向
}
delta = weight_delta.get(event.event_type, 0)
for tag in story.tags:
current = tag_weights.get(tag, 0)
tag_weights[tag] = max(0, current + delta) # 不低于0
# 更新阅读长度偏好
if event.event_type == "completed":
word_count = len(story.content)
if word_count < 300:
length_pref = "short"
elif word_count < 600:
length_pref = "medium"
else:
length_pref = "long"
# 简单的移动平均
prefs["preferred_length"] = length_pref
prefs["tag_weights"] = tag_weights
profile.reading_preferences = prefs
await db.commit()
```
---
## 六、数据迁移
```python
# backend/alembic/versions/xxx_add_child_profiles.py
def upgrade():
op.create_table(
'child_profiles',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(50), nullable=False),
sa.Column('avatar_url', sa.String(500)),
sa.Column('birth_date', sa.Date()),
sa.Column('gender', sa.String(10)),
sa.Column('interests', sa.JSON(), server_default='[]'),
sa.Column('growth_themes', sa.JSON(), server_default='[]'),
sa.Column('reading_preferences', sa.JSON(), server_default='{}'),
sa.Column('stories_count', sa.Integer(), server_default='0'),
sa.Column('total_reading_time', sa.Integer(), server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_child_profiles_user_id', 'child_profiles', ['user_id'])
def downgrade():
op.drop_index('idx_child_profiles_user_id')
op.drop_table('child_profiles')
```
---
## 七、隐私与安全
### 7.1 数据加密
敏感字段(姓名、出生日期)在存储时加密:
```python
from cryptography.fernet import Fernet
class EncryptedChildProfile:
"""加密存储的孩子档案"""
@staticmethod
def encrypt_name(name: str, key: bytes) -> str:
f = Fernet(key)
return f.encrypt(name.encode()).decode()
@staticmethod
def decrypt_name(encrypted: str, key: bytes) -> str:
f = Fernet(key)
return f.decrypt(encrypted.encode()).decode()
```
### 7.2 访问控制
- 孩子档案只能被创建者访问
- 删除用户时级联删除所有孩子档案
- API 层强制校验 `user_id` 归属
### 7.3 数据保留
- 用户可随时删除孩子档案
- 删除后 30 天内可恢复(软删除)
- 30 天后永久删除

File diff suppressed because it is too large Load Diff

View File

@@ -174,4 +174,4 @@
- PRD 里的“记忆系统”完整章节
- 数据模型(含字段 + 时序衰减)
- 交互与界面草案
- 后端实现拆解(任务清单 + 里程碑)
- 后端实现拆解(任务清单 + 里程碑)

View File

@@ -126,4 +126,4 @@ CREATE TABLE push_events (
## 八、相关文档
- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md)
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)

View File

@@ -228,4 +228,4 @@ def downgrade():
## 九、相关文档
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)
- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md)
- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md)

View File

@@ -1,130 +1,130 @@
# DreamWeaver 产品愿景与全流程规划
## 一、产品定位
### 1.1 愿景
**梦语织机** - 为 3-8 岁儿童打造的 AI 故事创作平台,让每个孩子都能拥有专属的成长故事。
### 1.2 核心价值
| 维度 | 价值主张 |
|------|----------|
| 个性化 | 基于关键词/主角定制,每个故事独一无二 |
| 教育性 | 融入成长主题(勇气、友谊、诚实等) |
| 沉浸感 | AI 封面 + 语音朗读,多感官体验 |
| 亲子互动 | 家长参与创作,增进亲子关系 |
### 1.3 目标用户
**主要用户家长25-40岁**
- 需求:为孩子找到有教育意义的睡前故事
- 痛点:市面故事千篇一律,缺乏个性化
- 场景:睡前、旅途、周末亲子时光
**次要用户:幼儿园/早教机构**
- 需求:批量生成教学故事素材
- 痛点:内容制作成本高
---
## 二、竞品分析
| 产品 | 优势 | 劣势 | 我们的差异化 |
|------|------|------|--------------|
| 凯叔讲故事 | 内容丰富、品牌知名 | 无个性化、订阅贵 | AI 个性化生成 |
| 喜马拉雅儿童 | 海量音频、多平台 | 内容同质化 | 用户参与创作 |
| ChatGPT | AI 能力强 | 非儿童专属、无配套 | 垂直场景优化 |
| Midjourney | 图像质量高 | 无故事整合 | 故事+图像+音频一体 |
---
## 三、产品路线图
### Phase 1: MVP 完善 ✅ 已完成
- [x] 关键词生成故事
- [x] 故事润色增强
- [x] AI 封面生成
- [x] 语音朗读
- [x] 故事收藏管理
- [x] OAuth 登录
- [x] 工程鲁棒性改进
### Phase 2: 体验增强
| 功能 | 优先级 | 用户价值 |
|------|--------|----------|
| 故事编辑 | P0 | 用户可修改 AI 生成内容 |
| 角色定制 | P0 | 孩子成为故事主角 |
| 故事续写 | P1 | 形成系列故事 |
| 多语言支持 | P1 | 英文故事学习 |
| 故事分享 | P1 | 社交传播 |
### Phase 3: 供应商平台化
| 功能 | 优先级 | 技术价值 |
|------|--------|----------|
| 供应商管理后台 | P0 | 可视化配置 AI 供应商 |
| 适配器插件化 | P0 | 新供应商零代码接入 |
| 供应商健康监控 | P1 | 自动故障转移 |
| A/B 测试框架 | P1 | 供应商效果对比 |
| 成本分析面板 | P2 | API 调用成本追踪 |
### Phase 4: 社区与增长
| 功能 | 优先级 | 增长价值 |
|------|--------|----------|
| 故事广场 | P0 | 内容发现 |
| 点赞/收藏 | P0 | 社区互动 |
| 创作者主页 | P1 | 用户留存 |
| 故事模板 | P1 | 降低创作门槛 |
### Phase 5: 商业化
| 功能 | 优先级 | 商业价值 |
|------|--------|----------|
| 会员订阅 | P0 | 核心收入 |
| 故事导出 | P0 | 增值服务 |
| 实体书打印 | P1 | 高客单价 |
| API 开放 | P2 | B 端收入 |
---
## 四、核心指标 (KPIs)
### 4.1 用户指标
| 指标 | 定义 | 目标 |
|------|------|------|
| DAU | 日活跃用户 | Phase 2: 1000+ |
| 留存率 | 次日/7日/30日 | 40%/25%/15% |
| 创作转化率 | 访问→创作 | 30%+ |
### 4.2 业务指标
| 指标 | 定义 | 目标 |
|------|------|------|
| 故事生成量 | 日均生成数 | 5000+ |
| 分享率 | 故事被分享比例 | 10%+ |
| 付费转化率 | 免费→付费 | 5%+ |
### 4.3 技术指标
| 指标 | 定义 | 目标 |
|------|------|------|
| API 成功率 | 供应商调用成功率 | 99%+ |
| 响应时间 | 故事生成 P95 | <30s |
| 成本/故事 | 单个故事 API 成本 | <$0.05 |
---
## 五、风险与应对
| 风险 | 影响 | 概率 | 应对策略 |
|------|------|------|----------|
| AI 生成内容不当 | 高 | 中 | 内容审核 + 家长控制 + 敏感词过滤 |
| API 成本过高 | 高 | 中 | 多供应商比价 + 缓存优化 + 分级限流 |
| 供应商服务中断 | 高 | 低 | 多供应商冗余 + 自动故障转移 |
| 用户增长缓慢 | 中 | 中 | 社区运营 + 分享裂变 + SEO |
| 竞品模仿 | 低 | 高 | 快速迭代 + 深耕垂直 + 数据壁垒 |
---
## 六、下一步讨论议题
1. **供应商平台化架构** - 如何设计插件化的适配器系统?
2. **Phase 2 功能优先级** - 先做哪个功能?
3. **技术选型** - nanobanana vs flux vs 其他图像供应商?
4. **商业模式** - 免费/付费边界在哪里?
请确认以上产品愿景是否符合预期,我们再深入讨论供应商平台化的技术架构。
# DreamWeaver 产品愿景与全流程规划
## 一、产品定位
### 1.1 愿景
**梦语织机** - 为 3-8 岁儿童打造的 AI 故事创作平台,让每个孩子都能拥有专属的成长故事。
### 1.2 核心价值
| 维度 | 价值主张 |
|------|----------|
| 个性化 | 基于关键词/主角定制,每个故事独一无二 |
| 教育性 | 融入成长主题(勇气、友谊、诚实等) |
| 沉浸感 | AI 封面 + 语音朗读,多感官体验 |
| 亲子互动 | 家长参与创作,增进亲子关系 |
### 1.3 目标用户
**主要用户家长25-40岁**
- 需求:为孩子找到有教育意义的睡前故事
- 痛点:市面故事千篇一律,缺乏个性化
- 场景:睡前、旅途、周末亲子时光
**次要用户:幼儿园/早教机构**
- 需求:批量生成教学故事素材
- 痛点:内容制作成本高
---
## 二、竞品分析
| 产品 | 优势 | 劣势 | 我们的差异化 |
|------|------|------|--------------|
| 凯叔讲故事 | 内容丰富、品牌知名 | 无个性化、订阅贵 | AI 个性化生成 |
| 喜马拉雅儿童 | 海量音频、多平台 | 内容同质化 | 用户参与创作 |
| ChatGPT | AI 能力强 | 非儿童专属、无配套 | 垂直场景优化 |
| Midjourney | 图像质量高 | 无故事整合 | 故事+图像+音频一体 |
---
## 三、产品路线图
### Phase 1: MVP 完善 ✅ 已完成
- [x] 关键词生成故事
- [x] 故事润色增强
- [x] AI 封面生成
- [x] 语音朗读
- [x] 故事收藏管理
- [x] OAuth 登录
- [x] 工程鲁棒性改进
### Phase 2: 体验增强
| 功能 | 优先级 | 用户价值 |
|------|--------|----------|
| 故事编辑 | P0 | 用户可修改 AI 生成内容 |
| 角色定制 | P0 | 孩子成为故事主角 |
| 故事续写 | P1 | 形成系列故事 |
| 多语言支持 | P1 | 英文故事学习 |
| 故事分享 | P1 | 社交传播 |
### Phase 3: 供应商平台化
| 功能 | 优先级 | 技术价值 |
|------|--------|----------|
| 供应商管理后台 | P0 | 可视化配置 AI 供应商 |
| 适配器插件化 | P0 | 新供应商零代码接入 |
| 供应商健康监控 | P1 | 自动故障转移 |
| A/B 测试框架 | P1 | 供应商效果对比 |
| 成本分析面板 | P2 | API 调用成本追踪 |
### Phase 4: 社区与增长
| 功能 | 优先级 | 增长价值 |
|------|--------|----------|
| 故事广场 | P0 | 内容发现 |
| 点赞/收藏 | P0 | 社区互动 |
| 创作者主页 | P1 | 用户留存 |
| 故事模板 | P1 | 降低创作门槛 |
### Phase 5: 商业化
| 功能 | 优先级 | 商业价值 |
|------|--------|----------|
| 会员订阅 | P0 | 核心收入 |
| 故事导出 | P0 | 增值服务 |
| 实体书打印 | P1 | 高客单价 |
| API 开放 | P2 | B 端收入 |
---
## 四、核心指标 (KPIs)
### 4.1 用户指标
| 指标 | 定义 | 目标 |
|------|------|------|
| DAU | 日活跃用户 | Phase 2: 1000+ |
| 留存率 | 次日/7日/30日 | 40%/25%/15% |
| 创作转化率 | 访问→创作 | 30%+ |
### 4.2 业务指标
| 指标 | 定义 | 目标 |
|------|------|------|
| 故事生成量 | 日均生成数 | 5000+ |
| 分享率 | 故事被分享比例 | 10%+ |
| 付费转化率 | 免费→付费 | 5%+ |
### 4.3 技术指标
| 指标 | 定义 | 目标 |
|------|------|------|
| API 成功率 | 供应商调用成功率 | 99%+ |
| 响应时间 | 故事生成 P95 | <30s |
| 成本/故事 | 单个故事 API 成本 | <$0.05 |
---
## 五、风险与应对
| 风险 | 影响 | 概率 | 应对策略 |
|------|------|------|----------|
| AI 生成内容不当 | 高 | 中 | 内容审核 + 家长控制 + 敏感词过滤 |
| API 成本过高 | 高 | 中 | 多供应商比价 + 缓存优化 + 分级限流 |
| 供应商服务中断 | 高 | 低 | 多供应商冗余 + 自动故障转移 |
| 用户增长缓慢 | 中 | 中 | 社区运营 + 分享裂变 + SEO |
| 竞品模仿 | 低 | 高 | 快速迭代 + 深耕垂直 + 数据壁垒 |
---
## 六、下一步讨论议题
1. **供应商平台化架构** - 如何设计插件化的适配器系统?
2. **Phase 2 功能优先级** - 先做哪个功能?
3. **技术选型** - nanobanana vs flux vs 其他图像供应商?
4. **商业模式** - 免费/付费边界在哪里?
请确认以上产品愿景是否符合预期,我们再深入讨论供应商平台化的技术架构。

File diff suppressed because it is too large Load Diff

View File

@@ -1,169 +1,169 @@
# DreamWeaver 产品路线图
## 产品愿景
**梦语织机** - 为 3-8 岁儿童打造的 AI 故事创作平台,让每个孩子都能拥有专属的成长故事。
### 核心价值主张
- **个性化**: 基于关键词生成独一无二的故事
- **教育性**: 融入成长主题(勇气、友谊、诚实等)
- **沉浸感**: AI 封面 + 语音朗读,多感官体验
- **亲子互动**: 家长参与创作,增进亲子关系
---
## 用户画像
### 主要用户家长25-40岁
- **需求**: 为孩子找到有教育意义的睡前故事
- **痛点**: 市面故事千篇一律,缺乏个性化
- **场景**: 睡前、旅途、周末亲子时光
### 次要用户:幼儿园/早教机构
- **需求**: 批量生成教学故事素材
- **痛点**: 内容制作成本高
- **场景**: 课堂教学、活动策划
---
## 功能规划
### Phase 1: MVP 完善(当前)
> 目标:核心体验闭环,用户可完整使用
| 功能 | 状态 | 说明 |
|------|------|------|
| 关键词生成故事 | ✅ 已完成 | 输入关键词AI 生成故事 |
| 故事润色增强 | ✅ 已完成 | 用户提供草稿AI 润色 |
| AI 封面生成 | ✅ 已完成 | 根据故事生成插画 |
| 语音朗读 | ✅ 已完成 | TTS 朗读故事 |
| 故事收藏管理 | ✅ 已完成 | 保存、查看、删除 |
| OAuth 登录 | ✅ 已完成 | GitHub/Google 登录 |
### Phase 2: 体验增强
> 目标:提升用户粘性,增加互动性
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **故事编辑** | P0 | 用户可修改 AI 生成的故事内容 |
| **角色定制** | P0 | 输入孩子姓名/性别,成为故事主角 |
| **故事续写** | P1 | 基于已有故事继续创作下一章 |
| **多语言支持** | P1 | 英文故事生成(已有 i18n 基础) |
| **故事分享** | P1 | 生成分享图片/链接 |
| **收藏夹/标签** | P2 | 故事分类管理 |
### Phase 3: 社区与增长
> 目标:构建用户社区,实现自然增长
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **故事广场** | P0 | 公开优质故事,用户可浏览 |
| **点赞/收藏** | P0 | 社区互动基础 |
| **故事模板** | P1 | 预设故事框架(冒险/友谊/成长) |
| **创作者主页** | P1 | 展示用户创作的故事集 |
| **评论系统** | P2 | 用户交流反馈 |
### Phase 4: 商业化
> 目标:建立可持续商业模式
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **会员订阅** | P0 | 免费/基础/高级三档 |
| **故事导出** | P0 | PDF/电子书格式导出 |
| **实体书打印** | P1 | 对接印刷服务,生成实体绘本 |
| **API 开放** | P2 | 为 B 端客户提供 API |
| **企业版** | P2 | 幼儿园/早教机构定制 |
---
## 技术架构演进
### 当前架构 (Phase 1)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Vue 3 │────▶│ FastAPI │────▶│ PostgreSQL │
│ Frontend │ │ Backend │ │ (Neon) │
└─────────────┘ └──────┬──────┘ └─────────────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌─────────┐
│ Gemini │ │ Minimax │ │ Flux │
│ (Text) │ │ (TTS) │ │ (Image) │
└────────┘ └─────────┘ └─────────┘
```
### Phase 2 架构演进
```
新增组件:
- Redis: 缓存 + 会话 + Rate Limit
- Celery: 异步任务队列(图片/音频生成)
- S3/OSS: 静态资源存储
```
### Phase 3 架构演进
```
新增组件:
- Elasticsearch: 故事全文搜索
- CDN: 静态资源加速
- 消息队列: 社区通知推送
```
---
## 里程碑规划
### M1: MVP 完善 ✅
- [x] 核心功能闭环
- [x] 工程鲁棒性改进
- [x] 测试覆盖
### M2: 体验增强
- [ ] 故事编辑功能
- [ ] 角色定制(孩子成为主角)
- [ ] 故事续写
- [ ] 多语言支持
- [ ] 分享功能
### M3: 社区上线
- [ ] 故事广场
- [ ] 用户互动(点赞/收藏)
- [ ] 创作者主页
### M4: 商业化
- [ ] 会员体系
- [ ] 故事导出
- [ ] 实体书打印
---
## 竞品分析
| 产品 | 优势 | 劣势 | 我们的差异化 |
|------|------|------|--------------|
| 凯叔讲故事 | 内容丰富、品牌知名 | 无个性化、订阅贵 | AI 个性化生成 |
| 喜马拉雅儿童 | 海量音频、多平台 | 内容同质化 | 用户参与创作 |
| ChatGPT | AI 能力强 | 非儿童专属、无配套 | 垂直场景优化 |
---
## 风险与应对
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| AI 生成内容不当 | 高 | 内容审核 + 家长控制 |
| API 成本过高 | 中 | 缓存优化 + 分级限流 |
| 用户增长缓慢 | 中 | 社区运营 + 分享裂变 |
| 竞品模仿 | 低 | 快速迭代 + 深耕垂直 |
---
## 下一步行动
**Phase 2 优先实现功能:**
1. **故事编辑** - 用户体验核心痛点
2. **角色定制** - 差异化竞争力
3. **故事分享** - 自然增长引擎
是否需要我为这些功能生成详细的技术规格文档?
# DreamWeaver 产品路线图
## 产品愿景
**梦语织机** - 为 3-8 岁儿童打造的 AI 故事创作平台,让每个孩子都能拥有专属的成长故事。
### 核心价值主张
- **个性化**: 基于关键词生成独一无二的故事
- **教育性**: 融入成长主题(勇气、友谊、诚实等)
- **沉浸感**: AI 封面 + 语音朗读,多感官体验
- **亲子互动**: 家长参与创作,增进亲子关系
---
## 用户画像
### 主要用户家长25-40岁
- **需求**: 为孩子找到有教育意义的睡前故事
- **痛点**: 市面故事千篇一律,缺乏个性化
- **场景**: 睡前、旅途、周末亲子时光
### 次要用户:幼儿园/早教机构
- **需求**: 批量生成教学故事素材
- **痛点**: 内容制作成本高
- **场景**: 课堂教学、活动策划
---
## 功能规划
### Phase 1: MVP 完善(当前)
> 目标:核心体验闭环,用户可完整使用
| 功能 | 状态 | 说明 |
|------|------|------|
| 关键词生成故事 | ✅ 已完成 | 输入关键词AI 生成故事 |
| 故事润色增强 | ✅ 已完成 | 用户提供草稿AI 润色 |
| AI 封面生成 | ✅ 已完成 | 根据故事生成插画 |
| 语音朗读 | ✅ 已完成 | TTS 朗读故事 |
| 故事收藏管理 | ✅ 已完成 | 保存、查看、删除 |
| OAuth 登录 | ✅ 已完成 | GitHub/Google 登录 |
### Phase 2: 体验增强
> 目标:提升用户粘性,增加互动性
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **故事编辑** | P0 | 用户可修改 AI 生成的故事内容 |
| **角色定制** | P0 | 输入孩子姓名/性别,成为故事主角 |
| **故事续写** | P1 | 基于已有故事继续创作下一章 |
| **多语言支持** | P1 | 英文故事生成(已有 i18n 基础) |
| **故事分享** | P1 | 生成分享图片/链接 |
| **收藏夹/标签** | P2 | 故事分类管理 |
### Phase 3: 社区与增长
> 目标:构建用户社区,实现自然增长
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **故事广场** | P0 | 公开优质故事,用户可浏览 |
| **点赞/收藏** | P0 | 社区互动基础 |
| **故事模板** | P1 | 预设故事框架(冒险/友谊/成长) |
| **创作者主页** | P1 | 展示用户创作的故事集 |
| **评论系统** | P2 | 用户交流反馈 |
### Phase 4: 商业化
> 目标:建立可持续商业模式
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **会员订阅** | P0 | 免费/基础/高级三档 |
| **故事导出** | P0 | PDF/电子书格式导出 |
| **实体书打印** | P1 | 对接印刷服务,生成实体绘本 |
| **API 开放** | P2 | 为 B 端客户提供 API |
| **企业版** | P2 | 幼儿园/早教机构定制 |
---
## 技术架构演进
### 当前架构 (Phase 1)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Vue 3 │────▶│ FastAPI │────▶│ PostgreSQL │
│ Frontend │ │ Backend │ │ (Neon) │
└─────────────┘ └──────┬──────┘ └─────────────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌─────────┐
│ Gemini │ │ Minimax │ │ Flux │
│ (Text) │ │ (TTS) │ │ (Image) │
└────────┘ └─────────┘ └─────────┘
```
### Phase 2 架构演进
```
新增组件:
- Redis: 缓存 + 会话 + Rate Limit
- Celery: 异步任务队列(图片/音频生成)
- S3/OSS: 静态资源存储
```
### Phase 3 架构演进
```
新增组件:
- Elasticsearch: 故事全文搜索
- CDN: 静态资源加速
- 消息队列: 社区通知推送
```
---
## 里程碑规划
### M1: MVP 完善 ✅
- [x] 核心功能闭环
- [x] 工程鲁棒性改进
- [x] 测试覆盖
### M2: 体验增强
- [ ] 故事编辑功能
- [ ] 角色定制(孩子成为主角)
- [ ] 故事续写
- [ ] 多语言支持
- [ ] 分享功能
### M3: 社区上线
- [ ] 故事广场
- [ ] 用户互动(点赞/收藏)
- [ ] 创作者主页
### M4: 商业化
- [ ] 会员体系
- [ ] 故事导出
- [ ] 实体书打印
---
## 竞品分析
| 产品 | 优势 | 劣势 | 我们的差异化 |
|------|------|------|--------------|
| 凯叔讲故事 | 内容丰富、品牌知名 | 无个性化、订阅贵 | AI 个性化生成 |
| 喜马拉雅儿童 | 海量音频、多平台 | 内容同质化 | 用户参与创作 |
| ChatGPT | AI 能力强 | 非儿童专属、无配套 | 垂直场景优化 |
---
## 风险与应对
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| AI 生成内容不当 | 高 | 内容审核 + 家长控制 |
| API 成本过高 | 中 | 缓存优化 + 分级限流 |
| 用户增长缓慢 | 中 | 社区运营 + 分享裂变 |
| 竞品模仿 | 低 | 快速迭代 + 深耕垂直 |
---
## 下一步行动
**Phase 2 优先实现功能:**
1. **故事编辑** - 用户体验核心痛点
2. **角色定制** - 差异化竞争力
3. **故事分享** - 自然增长引擎
是否需要我为这些功能生成详细的技术规格文档?

View File

@@ -1,49 +1,49 @@
# DreamWeaver 工程鲁棒性改进计划
## 概述
本计划旨在提升 DreamWeaver 项目的工程质量,包括测试覆盖、稳定性、可观测性等方面。
## 任务列表
### P0 - 关键问题修复
#### Task-1: 修复 Rate Limit 内存泄漏 ✅
- **文件**: `backend/app/api/stories.py`
- **方案**: 已迁移至 Redis 分布式限流,内存泄漏问题不再存在
#### Task-2: 添加核心 API 测试 ✅
- **文件**: `backend/tests/`
- **范围**: test_auth, test_stories, test_profiles, test_universes, test_push_configs, test_reading_events, test_provider_router
### P1 - 稳定性提升
#### Task-3: 添加 API 重试机制 ✅
- **方案**: 所有适配器已使用 `tenacity` 指数退避重试 (gemini, openai, cqtai, antigravity, minimax, elevenlabs)
#### Task-4: 添加结构化日志 ✅
- **文件**: `backend/app/core/logging.py`
- **方案**: structlog JSON/Console 双模式,所有适配器和 provider_router 已集成
### P2 - 代码优化
#### Task-5: 重构 Provider Router ✅
- **文件**: `backend/app/services/provider_router.py`
- **方案**: 已实现统一 `_route_with_failover` 函数
#### Task-6: 配置外部化 ✅
- **文件**: `backend/app/core/config.py`, `backend/app/services/provider_router.py`
- **方案**: 所有模型名已移至 Settings支持环境变量覆盖
#### Task-7: 修复脆弱的 URL 解析 ✅
- **状态**: `drawing.py` 已被适配器系统取代,不再存在
## 新增依赖 (已添加)
```toml
# pyproject.toml [project.dependencies]
cachetools>=5.0.0 # Task-1: TTL cache
tenacity>=8.0.0 # Task-3: 重试机制
structlog>=24.0.0 # Task-4: 结构化日志
# [project.optional-dependencies.dev]
pytest-cov>=4.0.0 # Task-2: 覆盖率报告
```
# DreamWeaver 工程鲁棒性改进计划
## 概述
本计划旨在提升 DreamWeaver 项目的工程质量,包括测试覆盖、稳定性、可观测性等方面。
## 任务列表
### P0 - 关键问题修复
#### Task-1: 修复 Rate Limit 内存泄漏 ✅
- **文件**: `backend/app/api/stories.py`
- **方案**: 已迁移至 Redis 分布式限流,内存泄漏问题不再存在
#### Task-2: 添加核心 API 测试 ✅
- **文件**: `backend/tests/`
- **范围**: test_auth, test_stories, test_profiles, test_universes, test_push_configs, test_reading_events, test_provider_router
### P1 - 稳定性提升
#### Task-3: 添加 API 重试机制 ✅
- **方案**: 所有适配器已使用 `tenacity` 指数退避重试 (gemini, openai, cqtai, antigravity, minimax, elevenlabs)
#### Task-4: 添加结构化日志 ✅
- **文件**: `backend/app/core/logging.py`
- **方案**: structlog JSON/Console 双模式,所有适配器和 provider_router 已集成
### P2 - 代码优化
#### Task-5: 重构 Provider Router ✅
- **文件**: `backend/app/services/provider_router.py`
- **方案**: 已实现统一 `_route_with_failover` 函数
#### Task-6: 配置外部化 ✅
- **文件**: `backend/app/core/config.py`, `backend/app/services/provider_router.py`
- **方案**: 所有模型名已移至 Settings支持环境变量覆盖
#### Task-7: 修复脆弱的 URL 解析 ✅
- **状态**: `drawing.py` 已被适配器系统取代,不再存在
## 新增依赖 (已添加)
```toml
# pyproject.toml [project.dependencies]
cachetools>=5.0.0 # Task-1: TTL cache
tenacity>=8.0.0 # Task-3: 重试机制
structlog>=24.0.0 # Task-4: 结构化日志
# [project.optional-dependencies.dev]
pytest-cov>=4.0.0 # Task-2: 覆盖率报告
```

View File

@@ -299,5 +299,5 @@ TASK-024 [x]: 性能优化 - 减少 backdrop-filter
阶段一 (TASK-001 ~ 003) → 阶段二 (TASK-004 ~ 012) → 阶段三 (TASK-013 ~ 021) → 阶段四 (TASK-022 ~ 024)
每个任务完成后运行 npm run build 确保无类型错误。

View File

@@ -1,189 +1,189 @@
# .github/workflows/build.yml
# 构建并推送 Docker 镜像到 GitHub Container Registry
#
# 触发条件:
# - push 到 main 分支
# - 手动触发 (workflow_dispatch)
# - 创建版本标签 (v*)
#
# 镜像命名:
# ghcr.io/<owner>/dreamweaver-backend:latest
# ghcr.io/<owner>/dreamweaver-frontend:latest
# ghcr.io/<owner>/dreamweaver-admin-frontend:latest
name: Build and Push Docker Images
on:
push:
branches: [main]
tags: ['v*']
paths:
- 'backend/**'
- 'frontend/**'
- 'admin-frontend/**'
- '.github/workflows/build.yml'
workflow_dispatch:
inputs:
force_build:
description: 'Force rebuild all images'
required: false
default: 'false'
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ${{ github.repository_owner }}/dreamweaver
jobs:
# ==============================================
# 检测变更的目录
# ==============================================
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
admin-frontend: ${{ steps.filter.outputs.admin-frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
admin-frontend:
- 'admin-frontend/**'
# ==============================================
# 构建后端镜像
# ==============================================
build-backend:
needs: changes
if: needs.changes.outputs.backend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==============================================
# 构建前端镜像
# ==============================================
build-frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==============================================
# 构建管理后台前端镜像
# ==============================================
build-admin-frontend:
needs: changes
if: needs.changes.outputs.admin-frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-admin-frontend
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./admin-frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# .github/workflows/build.yml
# 构建并推送 Docker 镜像到 GitHub Container Registry
#
# 触发条件:
# - push 到 main 分支
# - 手动触发 (workflow_dispatch)
# - 创建版本标签 (v*)
#
# 镜像命名:
# ghcr.io/<owner>/dreamweaver-backend:latest
# ghcr.io/<owner>/dreamweaver-frontend:latest
# ghcr.io/<owner>/dreamweaver-admin-frontend:latest
name: Build and Push Docker Images
on:
push:
branches: [main]
tags: ['v*']
paths:
- 'backend/**'
- 'frontend/**'
- 'admin-frontend/**'
- '.github/workflows/build.yml'
workflow_dispatch:
inputs:
force_build:
description: 'Force rebuild all images'
required: false
default: 'false'
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ${{ github.repository_owner }}/dreamweaver
jobs:
# ==============================================
# 检测变更的目录
# ==============================================
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
admin-frontend: ${{ steps.filter.outputs.admin-frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
admin-frontend:
- 'admin-frontend/**'
# ==============================================
# 构建后端镜像
# ==============================================
build-backend:
needs: changes
if: needs.changes.outputs.backend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==============================================
# 构建前端镜像
# ==============================================
build-frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==============================================
# 构建管理后台前端镜像
# ==============================================
build-admin-frontend:
needs: changes
if: needs.changes.outputs.admin-frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-admin-frontend
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./admin-frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

88
.gitignore vendored
View File

@@ -1,44 +1,44 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
ENV/
# Node
node_modules/
dist/
# IDE
.idea/
.vscode/
# Claude Code local settings
.claude/settings.local.json
*.swp
*.swo
# 环境变量
.env
# 测试
.pytest_cache/
.coverage
htmlcov/
# 其他
*.log
.DS_Store
nul
# Vite
*.timestamp-*.mjs
# Python packaging
*.egg-info/
# Alembic
alembic/__pycache__/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
ENV/
# Node
node_modules/
dist/
# IDE
.idea/
.vscode/
# Claude Code local settings
.claude/settings.local.json
*.swp
*.swo
# 环境变量
.env
# 测试
.pytest_cache/
.coverage
htmlcov/
# 其他
*.log
.DS_Store
nul
# Vite
*.timestamp-*.mjs
# Python packaging
*.egg-info/
# Alembic
alembic/__pycache__/

134
AGENTS.md Normal file
View File

@@ -0,0 +1,134 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
DreamWeaver (梦语织机) - AI-powered children's story generation app for ages 3-8. Features story generation from keywords, story enhancement, AI-generated cover images, and text-to-speech narration.
**Language:** Chinese (Simplified) for UI and comments.
## Tech Stack
- **Backend:** FastAPI + PostgreSQL (Neon) + SQLAlchemy (async) + Celery/Redis | **Python 3.11+**
- **Frontend:** Vue 3 + TypeScript + Pinia + Tailwind CSS + vue-i18n
- **Auth:** OAuth 2.0 (GitHub/Google) with JWT in httpOnly cookie
- **AI Services:** Text generation, image generation, TTS (pluggable adapters)
## Commands
```bash
# Backend
cd backend
pip install -e . # Install dependencies
pip install -e ".[dev]" # With dev tools (pytest, ruff)
uvicorn app.main:app --reload --port 8000 # Start dev server
# Celery worker (requires Redis)
celery -A app.tasks worker --loglevel=info
# Database migrations
alembic upgrade head # Run migrations
alembic revision -m "message" --autogenerate # Generate new migration
# Linting
ruff check app/ # Check code
ruff check app/ --fix # Auto-fix
# Testing
pytest # Run all tests
pytest tests/test_auth.py -v # Single test file
pytest -k "test_name" # Match pattern
# Frontend
cd frontend
npm install
npm run dev # Start dev server (port 5173)
npm run build # Type-check + build
```
## Architecture
```
backend/app/
├── main.py # FastAPI app entry, routes registration
├── api/
│ ├── auth.py # OAuth routes (GitHub/Google)
│ ├── stories.py # Story CRUD and AI generation endpoints
│ ├── profiles.py # User profile management
│ ├── universes.py # Story universe/world management
│ ├── reading_events.py # Reading progress tracking
│ ├── push_configs.py # Push notification settings
│ ├── admin_providers.py # Provider CRUD (admin)
│ └── admin_reload.py # Hot-reload providers (admin)
├── core/
│ ├── config.py # Pydantic settings from env
│ ├── deps.py # Dependency injection (auth, db session)
│ ├── security.py # JWT token create/verify
│ ├── prompts.py # AI prompt templates
│ └── admin_auth.py # Basic Auth for admin routes
├── db/
│ ├── database.py # SQLAlchemy async engine + session
│ ├── models.py # User, Story models
│ └── admin_models.py # Provider model
├── services/
│ ├── adapters/ # Capability adapters (text, image, tts)
│ ├── provider_router.py # Failover routing across providers
│ ├── provider_cache.py # In-memory provider config cache
│ ├── provider_metrics.py # Provider performance metrics
│ └── achievement_extractor.py # Extract achievements from stories
└── tasks/
├── achievements.py # Celery task: achievement processing
└── push_notifications.py # Celery task: push notifications
frontend/src/
├── api/client.ts # Axios wrapper with auth interceptors
├── stores/user.ts # Pinia user state
├── router.ts # Vue Router config
├── i18n.ts + locales/ # vue-i18n setup
├── components/ # Reusable Vue components
└── views/ # Page components
```
## Key Patterns
- **Async everywhere:** All database and API calls use async/await
- **Dependency injection:** FastAPI `Depends()` for auth and db session
- **JWT auth:** Stored in httpOnly cookie, validated via `get_current_user` dependency
- **Provider routing:** `provider_router.py` tries providers in order, auto-failover on error
- **Background tasks:** Celery workers handle achievements and push notifications
- **Proxy in dev:** Vite proxies `/api`, `/auth`, `/admin` to backend (see `vite.config.ts`)
## Provider System
AI providers are configured via env vars (`TEXT_PROVIDERS`, `IMAGE_PROVIDERS`, `TTS_PROVIDERS`) as JSON arrays. The router tries each in order and fails over automatically.
Admin console (disabled by default): Set `ENABLE_ADMIN_CONSOLE=true` to enable `/admin/providers` CRUD endpoints with Basic Auth (`ADMIN_USERNAME`/`ADMIN_PASSWORD`).
## Environment Variables
See `backend/.env.example` for required variables:
- `DATABASE_URL`, `SECRET_KEY` (required)
- OAuth: `GITHUB_CLIENT_ID/SECRET`, `GOOGLE_CLIENT_ID/SECRET`
- AI: `TEXT_API_KEY`, `TTS_API_BASE`, `TTS_API_KEY`, `IMAGE_API_KEY`
- Providers: `TEXT_PROVIDERS`, `IMAGE_PROVIDERS`, `TTS_PROVIDERS` (JSON arrays)
- Celery: `CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND` (Redis URLs)
- Admin: `ENABLE_ADMIN_CONSOLE`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`
## API Endpoints
| Method | Route | Description |
| ------------------- | -------------------------- | --------------------------- |
| GET | `/auth/{provider}/signin` | OAuth login |
| GET | `/auth/session` | Get current user |
| POST | `/api/generate` | Generate/enhance story |
| POST | `/api/image/generate/{id}` | Generate cover image |
| GET | `/api/audio/{id}` | Get TTS audio |
| GET | `/api/stories` | List stories (paginated) |
| GET/DELETE | `/api/stories/{id}` | Story CRUD |
| CRUD | `/api/profiles` | User profiles |
| CRUD | `/api/universes` | Story universes |
| CRUD | `/api/reading-events` | Reading progress |
| CRUD | `/api/push-configs` | Push notification settings |
| GET/POST/PUT/DELETE | `/admin/providers` | Provider management (admin) |

View File

@@ -1,17 +1,17 @@
# Dependencies
node_modules/
# Build
dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
# Logs
*.log
# Dependencies
node_modules/
# Build
dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
# Logs
*.log

View File

@@ -1,23 +1,23 @@
# Build Stage
FROM node:18-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production Stage
FROM nginx:alpine as production-stage
# 复制构建产物到 Nginx
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 复制自定义 Nginx 配置 (处理 SPA 路由)
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Build Stage
FROM node:18-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production Stage
FROM nginx:alpine as production-stage
# 复制构建产物到 Nginx
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 复制自定义 Nginx 配置 (处理 SPA 路由)
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DreamWeaver Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DreamWeaver Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,37 +1,37 @@
server {
listen 80;
server_name localhost;
# 静态文件服务
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# SPA 路由支持: 找不到文件时回退到 index.html
try_files $uri $uri/ /index.html;
}
# 反向代理: 将 /admin 请求转发给管理后端
location /admin/ {
proxy_pass http://backend-admin:8001/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 仍保留 /api 以备偶尔调用通用接口Auth等
location /api/ {
proxy_pass http://backend-admin:8001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 静态资源代理
location /static/ {
proxy_pass http://backend-admin:8001/static/;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
server {
listen 80;
server_name localhost;
# 静态文件服务
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# SPA 路由支持: 找不到文件时回退到 index.html
try_files $uri $uri/ /index.html;
}
# 反向代理: 将 /admin 请求转发给管理后端
location /admin/ {
proxy_pass http://backend-admin:8001/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 仍保留 /api 以备偶尔调用通用接口Auth等
location /api/ {
proxy_pass http://backend-admin:8001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 静态资源代理
location /static/ {
proxy_pass http://backend-admin:8001/static/;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,28 @@
{
"name": "dreamweaver-admin-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@vueuse/core": "^11.0.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-i18n": "^11.2.2",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
{
"name": "dreamweaver-admin-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@vueuse/core": "^11.0.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-i18n": "^11.2.2",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y=".9em" font-size="90"></text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y=".9em" font-size="90"></text>
</svg>

Before

Width:  |  Height:  |  Size: 113 B

After

Width:  |  Height:  |  Size: 116 B

View File

@@ -1,399 +1,399 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>梦语织机 - AI 儿童故事创作</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #0D0F1A;
--bg-card: #151829;
--bg-elevated: #1C2035;
--accent: #FFD369;
--accent-soft: #FFF0C9;
--text: #EAEAEA;
--text-secondary: #9CA3AF;
--text-muted: #6B7280;
--border: rgba(255,255,255,0.08);
--glow: rgba(255, 211, 105, 0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans SC', -apple-system, sans-serif;
background: var(--bg-deep);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
/* 动画背景 */
.animated-bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
.glow { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.5; will-change: transform; }
.glow-1 { width: 600px; height: 600px; background: radial-gradient(circle, rgba(99, 102, 241, 0.4) 0%, transparent 70%); top: -200px; left: -100px; animation: float1 20s ease-in-out infinite; }
.glow-2 { width: 500px; height: 500px; background: radial-gradient(circle, rgba(255, 211, 105, 0.3) 0%, transparent 70%); top: 30%; right: -150px; animation: float2 25s ease-in-out infinite; }
.glow-3 { width: 400px; height: 400px; background: radial-gradient(circle, rgba(168, 85, 247, 0.35) 0%, transparent 70%); bottom: -100px; left: 20%; animation: float3 22s ease-in-out infinite; }
@keyframes float1 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(100px, 50px) scale(1.1); } 66% { transform: translate(50px, 100px) scale(0.9); } }
@keyframes float2 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(-80px, 60px) scale(1.15); } 66% { transform: translate(-40px, -40px) scale(0.95); } }
@keyframes float3 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(60px, -50px) scale(1.05); } 66% { transform: translate(-30px, 30px) scale(1.1); } }
.grid-bg { position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); background-size: 60px 60px; mask-image: radial-gradient(ellipse 80% 50% at 50% 50%, black 40%, transparent 100%); }
.star { position: absolute; width: 2px; height: 2px; background: white; border-radius: 50%; animation: twinkle 3s ease-in-out infinite; }
@keyframes twinkle { 0%, 100% { opacity: 0.3; transform: scale(1); } 50% { opacity: 1; transform: scale(1.5); } }
/* 导航 */
nav { position: fixed; top: 0; left: 0; right: 0; padding: 16px 48px; display: flex; justify-content: space-between; align-items: center; background: rgba(13, 15, 26, 0.8); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); z-index: 100; }
.logo { font-size: 1.2rem; font-weight: 600; color: var(--accent); letter-spacing: 1px; }
.nav-links { display: flex; gap: 36px; list-style: none; }
.nav-links a { color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; transition: color 0.2s; }
.nav-links a:hover { color: var(--text); }
.nav-cta { padding: 10px 22px; background: var(--accent); color: var(--bg-deep); border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.nav-cta:hover { box-shadow: 0 0 20px var(--glow); }
/* 滚动动画 */
.fade-in { opacity: 0; transform: translateY(40px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in.visible { opacity: 1; transform: translateY(0); }
.fade-in-left { opacity: 0; transform: translateX(-60px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in-left.visible { opacity: 1; transform: translateX(0); }
.fade-in-right { opacity: 0; transform: translateX(60px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in-right.visible { opacity: 1; transform: translateX(0); }
.fade-in-scale { opacity: 0; transform: scale(0.9); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in-scale.visible { opacity: 1; transform: scale(1); }
.delay-1 { transition-delay: 0.1s; }
.delay-2 { transition-delay: 0.2s; }
.delay-3 { transition-delay: 0.3s; }
.delay-4 { transition-delay: 0.4s; }
.delay-5 { transition-delay: 0.5s; }
/* 按钮 */
.btn-primary { padding: 16px 32px; background: var(--accent); color: var(--bg-deep); border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.btn-primary:hover { box-shadow: 0 0 30px var(--glow); transform: translateY(-2px); }
.btn-ghost { padding: 16px 32px; background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 10px; font-size: 1rem; cursor: pointer; transition: all 0.2s; }
.btn-ghost:hover { background: var(--bg-elevated); border-color: var(--text-muted); }
/* Hero */
.hero { min-height: 100vh; display: flex; align-items: center; padding: 120px 48px 80px; max-width: 1400px; margin: 0 auto; position: relative; z-index: 1; }
.hero-content { flex: 1; max-width: 580px; }
.hero-badge { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 24px; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 28px; }
.hero-badge span { color: var(--accent); }
.hero-title { font-size: 3.5rem; font-weight: 700; line-height: 1.2; margin-bottom: 24px; }
.hero-title .highlight { color: var(--accent); }
.hero-desc { font-size: 1.15rem; color: var(--text-secondary); line-height: 1.8; margin-bottom: 40px; }
.hero-buttons { display: flex; gap: 16px; margin-bottom: 56px; }
.hero-visual { flex: 1; display: flex; justify-content: flex-end; padding-left: 60px; }
.visual-container { position: relative; width: 440px; }
/* 故事卡片 */
.main-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; padding: 24px; backdrop-filter: blur(10px); }
.card-cover { aspect-ratio: 16/10; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); border-radius: 14px; margin-bottom: 20px; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.card-cover::before { content: ''; position: absolute; width: 100px; height: 100px; background: radial-gradient(circle, var(--accent) 0%, transparent 70%); opacity: 0.3; top: 20%; left: 30%; filter: blur(20px); }
.card-cover-text { font-size: 3rem; opacity: 0.8; animation: bounce 2s ease-in-out infinite; }
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.card-title { font-size: 1.15rem; font-weight: 600; }
.card-badge { padding: 4px 10px; background: var(--glow); color: var(--accent); border-radius: 6px; font-size: 0.75rem; font-weight: 500; }
.card-excerpt { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.7; margin-bottom: 16px; }
.card-tags { display: flex; gap: 8px; margin-bottom: 20px; }
.tag { padding: 6px 12px; background: var(--bg-elevated); color: var(--text-muted); border-radius: 6px; font-size: 0.8rem; border: 1px solid var(--border); }
.card-actions { display: flex; gap: 10px; }
.action-btn { flex: 1; padding: 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; font-size: 0.85rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; }
.action-btn:hover { border-color: var(--accent); color: var(--accent); }
/* 浮动卡片 */
.float-card { position: absolute; background: var(--bg-card); border: 1px solid var(--border); padding: 12px 18px; border-radius: 12px; backdrop-filter: blur(10px); display: flex; align-items: center; gap: 12px; animation: floatCard 6s ease-in-out infinite; }
.float-card-1 { top: 20px; left: -60px; }
.float-card-2 { bottom: 80px; right: -40px; animation-delay: 1s; }
@keyframes floatCard { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-15px); } }
.float-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; background: var(--bg-elevated); }
.float-text { font-size: 0.85rem; color: var(--text-secondary); }
/* Trust Bar */
.trust-bar { padding: 60px 48px; background: rgba(255,255,255,0.02); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); position: relative; z-index: 1; }
.trust-container { max-width: 900px; margin: 0 auto; display: flex; justify-content: center; gap: 60px; flex-wrap: wrap; }
.stat-item { text-align: center; min-width: 140px; }
.stat-number { font-size: 2.5rem; font-weight: 700; color: var(--text); line-height: 1; margin-bottom: 8px; }
.stat-number .accent { color: var(--accent); }
.stat-label { font-size: 0.9rem; color: var(--text-muted); }
/* Section通用 */
.section { padding: 120px 48px; position: relative; z-index: 1; }
.section-header { text-align: center; margin-bottom: 72px; }
.section-label { font-size: 0.85rem; color: var(--accent); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 16px; }
.section-title { font-size: 2.5rem; font-weight: 600; }
/* Features */
.features-container { max-width: 1200px; margin: 0 auto; }
.features-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
.feature-card { background: var(--bg-card); border: 1px solid var(--border); padding: 36px 28px; border-radius: 16px; transition: all 0.3s; }
.feature-card:hover { border-color: rgba(255, 211, 105, 0.3); transform: translateY(-4px); }
.feature-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 24px; margin-bottom: 24px; background: var(--bg-elevated); border: 1px solid var(--border); }
.feature-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; }
.feature-desc { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
/* How It Works */
.steps-container { max-width: 800px; margin: 0 auto; }
.steps { display: flex; flex-direction: column; gap: 24px; }
.step { display: flex; align-items: flex-start; gap: 24px; background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 16px; padding: 28px; transition: all 0.3s; }
.step:hover { border-color: rgba(255, 211, 105, 0.3); }
.step-number { width: 56px; height: 56px; background: linear-gradient(135deg, var(--accent), #FF9F43); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 700; color: var(--bg-deep); flex-shrink: 0; }
.step-content h3 { font-size: 1.15rem; font-weight: 600; margin-bottom: 8px; }
.step-content p { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
/* FAQ */
.faq-container { max-width: 700px; margin: 0 auto; }
.faq-list { display: flex; flex-direction: column; gap: 12px; }
.faq-item { background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; transition: border-color 0.3s; }
.faq-item:hover { border-color: rgba(255,255,255,0.15); }
.faq-item.active { border-color: rgba(255, 211, 105, 0.3); }
.faq-question { width: 100%; padding: 20px 24px; background: none; border: none; color: var(--text); font-size: 1rem; font-weight: 500; text-align: left; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 16px; transition: color 0.2s; }
.faq-question:hover { color: var(--accent); }
.faq-item.active .faq-question { color: var(--accent); }
.faq-icon { width: 24px; height: 24px; flex-shrink: 0; position: relative; }
.faq-icon::before, .faq-icon::after { content: ''; position: absolute; background: currentColor; border-radius: 2px; transition: transform 0.3s ease; }
.faq-icon::before { width: 14px; height: 2px; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.faq-icon::after { width: 2px; height: 14px; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.faq-item.active .faq-icon::after { transform: translate(-50%, -50%) rotate(90deg); opacity: 0; }
.faq-answer-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; }
.faq-item.active .faq-answer-wrapper { grid-template-rows: 1fr; }
.faq-answer { overflow: hidden; }
.faq-answer-content { padding: 0 24px 20px; color: var(--text-secondary); line-height: 1.7; }
/* CTA */
.cta { text-align: center; }
.cta-container { max-width: 650px; margin: 0 auto; padding: 60px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 24px; }
.cta-title { font-size: 2rem; font-weight: 600; margin-bottom: 16px; }
.cta-desc { font-size: 1rem; color: var(--text-secondary); margin-bottom: 32px; }
/* 模态框 */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease; }
.modal-overlay.active { opacity: 1; visibility: visible; }
.modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; width: 100%; max-width: 500px; max-height: 90vh; overflow-y: auto; transform: scale(0.9) translateY(20px); opacity: 0; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; }
.modal-overlay.active .modal { transform: scale(1) translateY(0); opacity: 1; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); }
.modal-title { font-size: 1.25rem; font-weight: 600; }
.modal-close { width: 36px; height: 36px; border: none; background: rgba(255,255,255,0.05); border-radius: 10px; color: var(--text-secondary); font-size: 1.25rem; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); }
.modal-body { padding: 24px; }
.form-group { margin-bottom: 20px; }
.form-label { display: block; font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 8px; }
.form-input { width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); border-radius: 10px; color: var(--text); font-size: 1rem; transition: border-color 0.2s, box-shadow 0.2s; }
.form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(255, 211, 105, 0.1); }
.form-input::placeholder { color: var(--text-muted); }
textarea.form-input { min-height: 100px; resize: vertical; }
.tags-select { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-option { padding: 8px 16px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); border-radius: 20px; font-size: 0.9rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.tag-option:hover { border-color: rgba(255, 211, 105, 0.3); color: var(--text); }
.tag-option.selected { background: rgba(255, 211, 105, 0.15); border-color: var(--accent); color: var(--accent); }
.modal-footer { padding: 20px 24px; border-top: 1px solid var(--border); display: flex; gap: 12px; justify-content: flex-end; }
/* Footer */
.footer { padding: 40px 48px; background: var(--bg-elevated); border-top: 1px solid var(--border); text-align: center; position: relative; z-index: 1; }
.footer p { color: var(--text-muted); font-size: 0.9rem; }
/* 响应式 */
@media (max-width: 1100px) {
.hero { flex-direction: column; padding: 100px 24px 60px; }
.hero-content { max-width: 100%; text-align: center; }
.hero-title { font-size: 2.5rem; }
.hero-buttons { justify-content: center; }
.hero-visual { padding: 50px 0 0; }
.visual-container { width: 100%; max-width: 400px; }
.float-card { display: none; }
.features-grid { grid-template-columns: 1fr; }
nav { padding: 14px 20px; }
.nav-links { display: none; }
.section { padding: 80px 24px; }
.trust-bar { padding: 40px 24px; }
.trust-container { gap: 40px; }
.step { flex-direction: column; text-align: center; }
.cta-container { padding: 40px 24px; }
}
</style>
</head>
<body>
<div class="animated-bg">
<div class="glow glow-1"></div>
<div class="glow glow-2"></div>
<div class="glow glow-3"></div>
<div class="grid-bg"></div>
<div class="stars" id="stars"></div>
</div>
<nav>
<div class="logo">梦语织机</div>
<ul class="nav-links">
<li><a href="#features">功能</a></li>
<li><a href="#how-it-works">使用方法</a></li>
<li><a href="#faq">常见问题</a></li>
</ul>
<button class="nav-cta" onclick="openModal()">开始创作</button>
</nav>
<!-- Hero Section -->
<section class="hero">
<div class="hero-content">
<div class="hero-badge fade-in"><span></span> 专为 3-8 岁儿童设计</div>
<h1 class="hero-title fade-in delay-1">为孩子编织<br><span class="highlight">专属的童话梦境</span></h1>
<p class="hero-desc fade-in delay-2">输入几个关键词AI 即刻为孩子创作独一无二的睡前故事。温暖的声音,精美的插画,让每个夜晚都充满想象。</p>
<div class="hero-buttons fade-in delay-3">
<button class="btn-primary" onclick="openModal()">免费开始创作</button>
<button class="btn-ghost" onclick="document.getElementById('features').scrollIntoView({behavior:'smooth'})">了解更多</button>
</div>
</div>
<div class="hero-visual fade-in-right delay-2">
<div class="visual-container">
<div class="float-card float-card-1"><div class="float-icon">🎨</div><span class="float-text">AI 生成插画</span></div>
<div class="main-card">
<div class="card-cover"><span class="card-cover-text">🐰</span></div>
<div class="card-header"><h3 class="card-title">小兔子的勇气冒险</h3><span class="card-badge">刚刚生成</span></div>
<p class="card-excerpt">在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔。今天,她决定独自去森林深处...</p>
<div class="card-tags"><span class="tag">勇气</span><span class="tag">冒险</span><span class="tag">友谊</span></div>
<div class="card-actions"><button class="action-btn">🔊 播放朗读</button><button class="action-btn">🖼 生成插画</button></div>
</div>
<div class="float-card float-card-2"><div class="float-icon">🔊</div><span class="float-text">温暖语音朗读</span></div>
</div>
</div>
</section>
<!-- Trust Bar -->
<section class="trust-bar" id="trust-bar">
<div class="trust-container">
<div class="stat-item fade-in"><div class="stat-number"><span class="counter" data-target="10000">0</span><span class="accent">+</span></div><div class="stat-label">故事已创作</div></div>
<div class="stat-item fade-in delay-1"><div class="stat-number"><span class="counter" data-target="5000">0</span><span class="accent">+</span></div><div class="stat-label">家庭信赖</div></div>
<div class="stat-item fade-in delay-2"><div class="stat-number"><span class="counter" data-target="98">0</span><span class="accent">%</span></div><div class="stat-label">满意度</div></div>
</div>
</section>
<!-- Features -->
<section class="section" id="features">
<div class="features-container">
<div class="section-header"><p class="section-label fade-in">核心功能</p><h2 class="section-title fade-in delay-1">为什么选择梦语织机</h2></div>
<div class="features-grid">
<div class="feature-card fade-in-scale delay-1"><div class="feature-icon">✍️</div><h3 class="feature-title">智能创作</h3><p class="feature-desc">输入关键词或简单想法AI 即刻创作充满想象力的原创故事</p></div>
<div class="feature-card fade-in-scale delay-2"><div class="feature-icon">🧒</div><h3 class="feature-title">个性化记忆</h3><p class="feature-desc">系统记住孩子的喜好,故事越来越懂 TA</p></div>
<div class="feature-card fade-in-scale delay-3"><div class="feature-icon">🎨</div><h3 class="feature-title">精美插画</h3><p class="feature-desc">为每个故事自动生成独特的封面插画</p></div>
<div class="feature-card fade-in-scale delay-1"><div class="feature-icon">🔊</div><h3 class="feature-title">温暖朗读</h3><p class="feature-desc">专业配音,陪伴孩子进入甜美梦乡</p></div>
<div class="feature-card fade-in-scale delay-2"><div class="feature-icon">📚</div><h3 class="feature-title">教育主题</h3><p class="feature-desc">勇气、友谊、分享...自然传递正向价值观</p></div>
<div class="feature-card fade-in-scale delay-3"><div class="feature-icon">🌍</div><h3 class="feature-title">故事宇宙</h3><p class="feature-desc">创建专属世界观,角色可在不同故事中复用</p></div>
</div>
</div>
</section>
<!-- How It Works -->
<section class="section" id="how-it-works">
<div class="steps-container">
<div class="section-header"><p class="section-label fade-in">使用方法</p><h2 class="section-title fade-in delay-1">简单三步,创造专属故事</h2></div>
<div class="steps">
<div class="step fade-in-left delay-1"><div class="step-number">1</div><div class="step-content"><h3>输入灵感</h3><p>几个关键词或简单想法,比如"勇敢的小兔子在森林里冒险"</p></div></div>
<div class="step fade-in-right delay-2"><div class="step-number">2</div><div class="step-content"><h3>AI 创作</h3><p>AI 即刻理解并创作充满想象力的原创故事,还能生成精美插画</p></div></div>
<div class="step fade-in-left delay-3"><div class="step-number">3</div><div class="step-content"><h3>温暖朗读</h3><p>选择喜欢的声音,让温暖的朗读陪伴孩子进入甜美梦乡</p></div></div>
</div>
</div>
</section>
<!-- FAQ -->
<section class="section" id="faq">
<div class="faq-container">
<div class="section-header"><p class="section-label fade-in">常见问题</p><h2 class="section-title fade-in delay-1">你可能想知道</h2></div>
<div class="faq-list">
<div class="faq-item fade-in delay-1"><button class="faq-question" onclick="toggleFaq(this)"><span>梦语织机适合多大的孩子?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">梦语织机专为 3-8 岁儿童设计。我们的故事内容、语言难度和主题都经过精心调整,确保适合这个年龄段孩子的认知发展水平。</div></div></div></div>
<div class="faq-item fade-in delay-2"><button class="faq-question" onclick="toggleFaq(this)"><span>生成的故事内容安全吗?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">绝对安全。所有生成的故事都经过多层内容审核,确保不包含任何不适合儿童的内容。我们的 AI 模型经过专门训练,只会生成积极、正向、富有教育意义的故事内容。</div></div></div></div>
<div class="faq-item fade-in delay-3"><button class="faq-question" onclick="toggleFaq(this)"><span>可以自定义故事的主角吗?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">当然可以您可以输入孩子喜欢的角色名称、特征甚至可以把孩子自己设定为故事主角。AI 会根据您的输入创作独一无二的个性化故事。</div></div></div></div>
<div class="faq-item fade-in delay-4"><button class="faq-question" onclick="toggleFaq(this)"><span>免费版和付费版有什么区别?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">免费版每月可生成 5 个故事包含基础的语音朗读功能。付费版提供无限故事生成、高级语音选择、AI 插画生成、故事导出等更多功能。</div></div></div></div>
</div>
</div>
</section>
<!-- CTA -->
<section class="section cta">
<div class="cta-container fade-in-scale">
<h2 class="cta-title">准备好为孩子创造魔法了吗?</h2>
<p class="cta-desc">免费开始,无需信用卡</p>
<button class="btn-primary" onclick="openModal()">立即开始创作</button>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<p>© 2024 梦语织机 DreamWeaver. All rights reserved.</p>
</footer>
<!-- 创作模态框 -->
<div class="modal-overlay" id="createModal" onclick="closeModalOnOverlay(event)">
<div class="modal">
<div class="modal-header"><h2 class="modal-title">创作新故事</h2><button class="modal-close" onclick="closeModal()">×</button></div>
<div class="modal-body">
<div class="form-group"><label class="form-label">故事主角</label><input type="text" class="form-input" placeholder="例如:小兔子、勇敢的公主..."></div>
<div class="form-group"><label class="form-label">故事场景</label><input type="text" class="form-input" placeholder="例如:魔法森林、星空下..."></div>
<div class="form-group"><label class="form-label">选择主题</label><div class="tags-select"><span class="tag-option" onclick="toggleTag(this)">勇气</span><span class="tag-option" onclick="toggleTag(this)">友谊</span><span class="tag-option" onclick="toggleTag(this)">冒险</span><span class="tag-option" onclick="toggleTag(this)">分享</span><span class="tag-option" onclick="toggleTag(this)">成长</span></div></div>
<div class="form-group"><label class="form-label">额外要求(可选)</label><textarea class="form-input" placeholder="任何特殊要求..."></textarea></div>
</div>
<div class="modal-footer"><button class="btn-ghost" onclick="closeModal()">取消</button><button class="btn-primary">开始创作 ✨</button></div>
</div>
</div>
<script>
// 生成星星
const starsContainer = document.getElementById('stars');
for (let i = 0; i < 50; i++) {
const star = document.createElement('div');
star.className = 'star';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
star.style.animationDelay = Math.random() * 3 + 's';
star.style.animationDuration = (2 + Math.random() * 2) + 's';
starsContainer.appendChild(star);
}
// 滚动动画
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) entry.target.classList.add('visible');
});
}, { threshold: 0.15 });
document.querySelectorAll('.fade-in, .fade-in-left, .fade-in-right, .fade-in-scale').forEach(el => observer.observe(el));
// 数字计数动画
function easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }
function animateCounter(el, target, duration = 2000) {
const start = performance.now();
function update(now) {
const progress = Math.min((now - start) / duration, 1);
el.textContent = Math.floor(target * easeOutQuart(progress)).toLocaleString();
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
const counterObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.querySelectorAll('.counter').forEach((c, i) => {
setTimeout(() => animateCounter(c, parseInt(c.dataset.target)), i * 200);
});
obs.unobserve(entry.target);
}
});
}, { threshold: 0.3 });
document.querySelectorAll('#trust-bar').forEach(el => counterObserver.observe(el));
// FAQ 手风琴
function toggleFaq(btn) { btn.closest('.faq-item').classList.toggle('active'); }
// 模态框
let lastFocus = null;
function openModal() {
lastFocus = document.activeElement;
document.getElementById('createModal').classList.add('active');
document.body.style.overflow = 'hidden';
setTimeout(() => document.querySelector('.modal .form-input')?.focus(), 100);
}
function closeModal() {
document.getElementById('createModal').classList.remove('active');
document.body.style.overflow = '';
lastFocus?.focus();
}
function closeModalOnOverlay(e) { if (e.target.classList.contains('modal-overlay')) closeModal(); }
function toggleTag(el) { el.classList.toggle('selected'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
</script>
</body>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>梦语织机 - AI 儿童故事创作</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #0D0F1A;
--bg-card: #151829;
--bg-elevated: #1C2035;
--accent: #FFD369;
--accent-soft: #FFF0C9;
--text: #EAEAEA;
--text-secondary: #9CA3AF;
--text-muted: #6B7280;
--border: rgba(255,255,255,0.08);
--glow: rgba(255, 211, 105, 0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans SC', -apple-system, sans-serif;
background: var(--bg-deep);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
/* 动画背景 */
.animated-bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
.glow { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.5; will-change: transform; }
.glow-1 { width: 600px; height: 600px; background: radial-gradient(circle, rgba(99, 102, 241, 0.4) 0%, transparent 70%); top: -200px; left: -100px; animation: float1 20s ease-in-out infinite; }
.glow-2 { width: 500px; height: 500px; background: radial-gradient(circle, rgba(255, 211, 105, 0.3) 0%, transparent 70%); top: 30%; right: -150px; animation: float2 25s ease-in-out infinite; }
.glow-3 { width: 400px; height: 400px; background: radial-gradient(circle, rgba(168, 85, 247, 0.35) 0%, transparent 70%); bottom: -100px; left: 20%; animation: float3 22s ease-in-out infinite; }
@keyframes float1 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(100px, 50px) scale(1.1); } 66% { transform: translate(50px, 100px) scale(0.9); } }
@keyframes float2 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(-80px, 60px) scale(1.15); } 66% { transform: translate(-40px, -40px) scale(0.95); } }
@keyframes float3 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(60px, -50px) scale(1.05); } 66% { transform: translate(-30px, 30px) scale(1.1); } }
.grid-bg { position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); background-size: 60px 60px; mask-image: radial-gradient(ellipse 80% 50% at 50% 50%, black 40%, transparent 100%); }
.star { position: absolute; width: 2px; height: 2px; background: white; border-radius: 50%; animation: twinkle 3s ease-in-out infinite; }
@keyframes twinkle { 0%, 100% { opacity: 0.3; transform: scale(1); } 50% { opacity: 1; transform: scale(1.5); } }
/* 导航 */
nav { position: fixed; top: 0; left: 0; right: 0; padding: 16px 48px; display: flex; justify-content: space-between; align-items: center; background: rgba(13, 15, 26, 0.8); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); z-index: 100; }
.logo { font-size: 1.2rem; font-weight: 600; color: var(--accent); letter-spacing: 1px; }
.nav-links { display: flex; gap: 36px; list-style: none; }
.nav-links a { color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; transition: color 0.2s; }
.nav-links a:hover { color: var(--text); }
.nav-cta { padding: 10px 22px; background: var(--accent); color: var(--bg-deep); border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.nav-cta:hover { box-shadow: 0 0 20px var(--glow); }
/* 滚动动画 */
.fade-in { opacity: 0; transform: translateY(40px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in.visible { opacity: 1; transform: translateY(0); }
.fade-in-left { opacity: 0; transform: translateX(-60px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in-left.visible { opacity: 1; transform: translateX(0); }
.fade-in-right { opacity: 0; transform: translateX(60px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in-right.visible { opacity: 1; transform: translateX(0); }
.fade-in-scale { opacity: 0; transform: scale(0.9); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
.fade-in-scale.visible { opacity: 1; transform: scale(1); }
.delay-1 { transition-delay: 0.1s; }
.delay-2 { transition-delay: 0.2s; }
.delay-3 { transition-delay: 0.3s; }
.delay-4 { transition-delay: 0.4s; }
.delay-5 { transition-delay: 0.5s; }
/* 按钮 */
.btn-primary { padding: 16px 32px; background: var(--accent); color: var(--bg-deep); border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.btn-primary:hover { box-shadow: 0 0 30px var(--glow); transform: translateY(-2px); }
.btn-ghost { padding: 16px 32px; background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 10px; font-size: 1rem; cursor: pointer; transition: all 0.2s; }
.btn-ghost:hover { background: var(--bg-elevated); border-color: var(--text-muted); }
/* Hero */
.hero { min-height: 100vh; display: flex; align-items: center; padding: 120px 48px 80px; max-width: 1400px; margin: 0 auto; position: relative; z-index: 1; }
.hero-content { flex: 1; max-width: 580px; }
.hero-badge { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 24px; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 28px; }
.hero-badge span { color: var(--accent); }
.hero-title { font-size: 3.5rem; font-weight: 700; line-height: 1.2; margin-bottom: 24px; }
.hero-title .highlight { color: var(--accent); }
.hero-desc { font-size: 1.15rem; color: var(--text-secondary); line-height: 1.8; margin-bottom: 40px; }
.hero-buttons { display: flex; gap: 16px; margin-bottom: 56px; }
.hero-visual { flex: 1; display: flex; justify-content: flex-end; padding-left: 60px; }
.visual-container { position: relative; width: 440px; }
/* 故事卡片 */
.main-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; padding: 24px; backdrop-filter: blur(10px); }
.card-cover { aspect-ratio: 16/10; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); border-radius: 14px; margin-bottom: 20px; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.card-cover::before { content: ''; position: absolute; width: 100px; height: 100px; background: radial-gradient(circle, var(--accent) 0%, transparent 70%); opacity: 0.3; top: 20%; left: 30%; filter: blur(20px); }
.card-cover-text { font-size: 3rem; opacity: 0.8; animation: bounce 2s ease-in-out infinite; }
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.card-title { font-size: 1.15rem; font-weight: 600; }
.card-badge { padding: 4px 10px; background: var(--glow); color: var(--accent); border-radius: 6px; font-size: 0.75rem; font-weight: 500; }
.card-excerpt { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.7; margin-bottom: 16px; }
.card-tags { display: flex; gap: 8px; margin-bottom: 20px; }
.tag { padding: 6px 12px; background: var(--bg-elevated); color: var(--text-muted); border-radius: 6px; font-size: 0.8rem; border: 1px solid var(--border); }
.card-actions { display: flex; gap: 10px; }
.action-btn { flex: 1; padding: 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; font-size: 0.85rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; }
.action-btn:hover { border-color: var(--accent); color: var(--accent); }
/* 浮动卡片 */
.float-card { position: absolute; background: var(--bg-card); border: 1px solid var(--border); padding: 12px 18px; border-radius: 12px; backdrop-filter: blur(10px); display: flex; align-items: center; gap: 12px; animation: floatCard 6s ease-in-out infinite; }
.float-card-1 { top: 20px; left: -60px; }
.float-card-2 { bottom: 80px; right: -40px; animation-delay: 1s; }
@keyframes floatCard { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-15px); } }
.float-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; background: var(--bg-elevated); }
.float-text { font-size: 0.85rem; color: var(--text-secondary); }
/* Trust Bar */
.trust-bar { padding: 60px 48px; background: rgba(255,255,255,0.02); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); position: relative; z-index: 1; }
.trust-container { max-width: 900px; margin: 0 auto; display: flex; justify-content: center; gap: 60px; flex-wrap: wrap; }
.stat-item { text-align: center; min-width: 140px; }
.stat-number { font-size: 2.5rem; font-weight: 700; color: var(--text); line-height: 1; margin-bottom: 8px; }
.stat-number .accent { color: var(--accent); }
.stat-label { font-size: 0.9rem; color: var(--text-muted); }
/* Section通用 */
.section { padding: 120px 48px; position: relative; z-index: 1; }
.section-header { text-align: center; margin-bottom: 72px; }
.section-label { font-size: 0.85rem; color: var(--accent); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 16px; }
.section-title { font-size: 2.5rem; font-weight: 600; }
/* Features */
.features-container { max-width: 1200px; margin: 0 auto; }
.features-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
.feature-card { background: var(--bg-card); border: 1px solid var(--border); padding: 36px 28px; border-radius: 16px; transition: all 0.3s; }
.feature-card:hover { border-color: rgba(255, 211, 105, 0.3); transform: translateY(-4px); }
.feature-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 24px; margin-bottom: 24px; background: var(--bg-elevated); border: 1px solid var(--border); }
.feature-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; }
.feature-desc { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
/* How It Works */
.steps-container { max-width: 800px; margin: 0 auto; }
.steps { display: flex; flex-direction: column; gap: 24px; }
.step { display: flex; align-items: flex-start; gap: 24px; background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 16px; padding: 28px; transition: all 0.3s; }
.step:hover { border-color: rgba(255, 211, 105, 0.3); }
.step-number { width: 56px; height: 56px; background: linear-gradient(135deg, var(--accent), #FF9F43); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 700; color: var(--bg-deep); flex-shrink: 0; }
.step-content h3 { font-size: 1.15rem; font-weight: 600; margin-bottom: 8px; }
.step-content p { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
/* FAQ */
.faq-container { max-width: 700px; margin: 0 auto; }
.faq-list { display: flex; flex-direction: column; gap: 12px; }
.faq-item { background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; transition: border-color 0.3s; }
.faq-item:hover { border-color: rgba(255,255,255,0.15); }
.faq-item.active { border-color: rgba(255, 211, 105, 0.3); }
.faq-question { width: 100%; padding: 20px 24px; background: none; border: none; color: var(--text); font-size: 1rem; font-weight: 500; text-align: left; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 16px; transition: color 0.2s; }
.faq-question:hover { color: var(--accent); }
.faq-item.active .faq-question { color: var(--accent); }
.faq-icon { width: 24px; height: 24px; flex-shrink: 0; position: relative; }
.faq-icon::before, .faq-icon::after { content: ''; position: absolute; background: currentColor; border-radius: 2px; transition: transform 0.3s ease; }
.faq-icon::before { width: 14px; height: 2px; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.faq-icon::after { width: 2px; height: 14px; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.faq-item.active .faq-icon::after { transform: translate(-50%, -50%) rotate(90deg); opacity: 0; }
.faq-answer-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; }
.faq-item.active .faq-answer-wrapper { grid-template-rows: 1fr; }
.faq-answer { overflow: hidden; }
.faq-answer-content { padding: 0 24px 20px; color: var(--text-secondary); line-height: 1.7; }
/* CTA */
.cta { text-align: center; }
.cta-container { max-width: 650px; margin: 0 auto; padding: 60px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 24px; }
.cta-title { font-size: 2rem; font-weight: 600; margin-bottom: 16px; }
.cta-desc { font-size: 1rem; color: var(--text-secondary); margin-bottom: 32px; }
/* 模态框 */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease; }
.modal-overlay.active { opacity: 1; visibility: visible; }
.modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; width: 100%; max-width: 500px; max-height: 90vh; overflow-y: auto; transform: scale(0.9) translateY(20px); opacity: 0; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; }
.modal-overlay.active .modal { transform: scale(1) translateY(0); opacity: 1; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); }
.modal-title { font-size: 1.25rem; font-weight: 600; }
.modal-close { width: 36px; height: 36px; border: none; background: rgba(255,255,255,0.05); border-radius: 10px; color: var(--text-secondary); font-size: 1.25rem; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); }
.modal-body { padding: 24px; }
.form-group { margin-bottom: 20px; }
.form-label { display: block; font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 8px; }
.form-input { width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); border-radius: 10px; color: var(--text); font-size: 1rem; transition: border-color 0.2s, box-shadow 0.2s; }
.form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(255, 211, 105, 0.1); }
.form-input::placeholder { color: var(--text-muted); }
textarea.form-input { min-height: 100px; resize: vertical; }
.tags-select { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-option { padding: 8px 16px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); border-radius: 20px; font-size: 0.9rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.tag-option:hover { border-color: rgba(255, 211, 105, 0.3); color: var(--text); }
.tag-option.selected { background: rgba(255, 211, 105, 0.15); border-color: var(--accent); color: var(--accent); }
.modal-footer { padding: 20px 24px; border-top: 1px solid var(--border); display: flex; gap: 12px; justify-content: flex-end; }
/* Footer */
.footer { padding: 40px 48px; background: var(--bg-elevated); border-top: 1px solid var(--border); text-align: center; position: relative; z-index: 1; }
.footer p { color: var(--text-muted); font-size: 0.9rem; }
/* 响应式 */
@media (max-width: 1100px) {
.hero { flex-direction: column; padding: 100px 24px 60px; }
.hero-content { max-width: 100%; text-align: center; }
.hero-title { font-size: 2.5rem; }
.hero-buttons { justify-content: center; }
.hero-visual { padding: 50px 0 0; }
.visual-container { width: 100%; max-width: 400px; }
.float-card { display: none; }
.features-grid { grid-template-columns: 1fr; }
nav { padding: 14px 20px; }
.nav-links { display: none; }
.section { padding: 80px 24px; }
.trust-bar { padding: 40px 24px; }
.trust-container { gap: 40px; }
.step { flex-direction: column; text-align: center; }
.cta-container { padding: 40px 24px; }
}
</style>
</head>
<body>
<div class="animated-bg">
<div class="glow glow-1"></div>
<div class="glow glow-2"></div>
<div class="glow glow-3"></div>
<div class="grid-bg"></div>
<div class="stars" id="stars"></div>
</div>
<nav>
<div class="logo">梦语织机</div>
<ul class="nav-links">
<li><a href="#features">功能</a></li>
<li><a href="#how-it-works">使用方法</a></li>
<li><a href="#faq">常见问题</a></li>
</ul>
<button class="nav-cta" onclick="openModal()">开始创作</button>
</nav>
<!-- Hero Section -->
<section class="hero">
<div class="hero-content">
<div class="hero-badge fade-in"><span></span> 专为 3-8 岁儿童设计</div>
<h1 class="hero-title fade-in delay-1">为孩子编织<br><span class="highlight">专属的童话梦境</span></h1>
<p class="hero-desc fade-in delay-2">输入几个关键词AI 即刻为孩子创作独一无二的睡前故事。温暖的声音,精美的插画,让每个夜晚都充满想象。</p>
<div class="hero-buttons fade-in delay-3">
<button class="btn-primary" onclick="openModal()">免费开始创作</button>
<button class="btn-ghost" onclick="document.getElementById('features').scrollIntoView({behavior:'smooth'})">了解更多</button>
</div>
</div>
<div class="hero-visual fade-in-right delay-2">
<div class="visual-container">
<div class="float-card float-card-1"><div class="float-icon">🎨</div><span class="float-text">AI 生成插画</span></div>
<div class="main-card">
<div class="card-cover"><span class="card-cover-text">🐰</span></div>
<div class="card-header"><h3 class="card-title">小兔子的勇气冒险</h3><span class="card-badge">刚刚生成</span></div>
<p class="card-excerpt">在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔。今天,她决定独自去森林深处...</p>
<div class="card-tags"><span class="tag">勇气</span><span class="tag">冒险</span><span class="tag">友谊</span></div>
<div class="card-actions"><button class="action-btn">🔊 播放朗读</button><button class="action-btn">🖼 生成插画</button></div>
</div>
<div class="float-card float-card-2"><div class="float-icon">🔊</div><span class="float-text">温暖语音朗读</span></div>
</div>
</div>
</section>
<!-- Trust Bar -->
<section class="trust-bar" id="trust-bar">
<div class="trust-container">
<div class="stat-item fade-in"><div class="stat-number"><span class="counter" data-target="10000">0</span><span class="accent">+</span></div><div class="stat-label">故事已创作</div></div>
<div class="stat-item fade-in delay-1"><div class="stat-number"><span class="counter" data-target="5000">0</span><span class="accent">+</span></div><div class="stat-label">家庭信赖</div></div>
<div class="stat-item fade-in delay-2"><div class="stat-number"><span class="counter" data-target="98">0</span><span class="accent">%</span></div><div class="stat-label">满意度</div></div>
</div>
</section>
<!-- Features -->
<section class="section" id="features">
<div class="features-container">
<div class="section-header"><p class="section-label fade-in">核心功能</p><h2 class="section-title fade-in delay-1">为什么选择梦语织机</h2></div>
<div class="features-grid">
<div class="feature-card fade-in-scale delay-1"><div class="feature-icon">✍️</div><h3 class="feature-title">智能创作</h3><p class="feature-desc">输入关键词或简单想法AI 即刻创作充满想象力的原创故事</p></div>
<div class="feature-card fade-in-scale delay-2"><div class="feature-icon">🧒</div><h3 class="feature-title">个性化记忆</h3><p class="feature-desc">系统记住孩子的喜好,故事越来越懂 TA</p></div>
<div class="feature-card fade-in-scale delay-3"><div class="feature-icon">🎨</div><h3 class="feature-title">精美插画</h3><p class="feature-desc">为每个故事自动生成独特的封面插画</p></div>
<div class="feature-card fade-in-scale delay-1"><div class="feature-icon">🔊</div><h3 class="feature-title">温暖朗读</h3><p class="feature-desc">专业配音,陪伴孩子进入甜美梦乡</p></div>
<div class="feature-card fade-in-scale delay-2"><div class="feature-icon">📚</div><h3 class="feature-title">教育主题</h3><p class="feature-desc">勇气、友谊、分享...自然传递正向价值观</p></div>
<div class="feature-card fade-in-scale delay-3"><div class="feature-icon">🌍</div><h3 class="feature-title">故事宇宙</h3><p class="feature-desc">创建专属世界观,角色可在不同故事中复用</p></div>
</div>
</div>
</section>
<!-- How It Works -->
<section class="section" id="how-it-works">
<div class="steps-container">
<div class="section-header"><p class="section-label fade-in">使用方法</p><h2 class="section-title fade-in delay-1">简单三步,创造专属故事</h2></div>
<div class="steps">
<div class="step fade-in-left delay-1"><div class="step-number">1</div><div class="step-content"><h3>输入灵感</h3><p>几个关键词或简单想法,比如"勇敢的小兔子在森林里冒险"</p></div></div>
<div class="step fade-in-right delay-2"><div class="step-number">2</div><div class="step-content"><h3>AI 创作</h3><p>AI 即刻理解并创作充满想象力的原创故事,还能生成精美插画</p></div></div>
<div class="step fade-in-left delay-3"><div class="step-number">3</div><div class="step-content"><h3>温暖朗读</h3><p>选择喜欢的声音,让温暖的朗读陪伴孩子进入甜美梦乡</p></div></div>
</div>
</div>
</section>
<!-- FAQ -->
<section class="section" id="faq">
<div class="faq-container">
<div class="section-header"><p class="section-label fade-in">常见问题</p><h2 class="section-title fade-in delay-1">你可能想知道</h2></div>
<div class="faq-list">
<div class="faq-item fade-in delay-1"><button class="faq-question" onclick="toggleFaq(this)"><span>梦语织机适合多大的孩子?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">梦语织机专为 3-8 岁儿童设计。我们的故事内容、语言难度和主题都经过精心调整,确保适合这个年龄段孩子的认知发展水平。</div></div></div></div>
<div class="faq-item fade-in delay-2"><button class="faq-question" onclick="toggleFaq(this)"><span>生成的故事内容安全吗?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">绝对安全。所有生成的故事都经过多层内容审核,确保不包含任何不适合儿童的内容。我们的 AI 模型经过专门训练,只会生成积极、正向、富有教育意义的故事内容。</div></div></div></div>
<div class="faq-item fade-in delay-3"><button class="faq-question" onclick="toggleFaq(this)"><span>可以自定义故事的主角吗?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">当然可以您可以输入孩子喜欢的角色名称、特征甚至可以把孩子自己设定为故事主角。AI 会根据您的输入创作独一无二的个性化故事。</div></div></div></div>
<div class="faq-item fade-in delay-4"><button class="faq-question" onclick="toggleFaq(this)"><span>免费版和付费版有什么区别?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">免费版每月可生成 5 个故事包含基础的语音朗读功能。付费版提供无限故事生成、高级语音选择、AI 插画生成、故事导出等更多功能。</div></div></div></div>
</div>
</div>
</section>
<!-- CTA -->
<section class="section cta">
<div class="cta-container fade-in-scale">
<h2 class="cta-title">准备好为孩子创造魔法了吗?</h2>
<p class="cta-desc">免费开始,无需信用卡</p>
<button class="btn-primary" onclick="openModal()">立即开始创作</button>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<p>© 2024 梦语织机 DreamWeaver. All rights reserved.</p>
</footer>
<!-- 创作模态框 -->
<div class="modal-overlay" id="createModal" onclick="closeModalOnOverlay(event)">
<div class="modal">
<div class="modal-header"><h2 class="modal-title">创作新故事</h2><button class="modal-close" onclick="closeModal()">×</button></div>
<div class="modal-body">
<div class="form-group"><label class="form-label">故事主角</label><input type="text" class="form-input" placeholder="例如:小兔子、勇敢的公主..."></div>
<div class="form-group"><label class="form-label">故事场景</label><input type="text" class="form-input" placeholder="例如:魔法森林、星空下..."></div>
<div class="form-group"><label class="form-label">选择主题</label><div class="tags-select"><span class="tag-option" onclick="toggleTag(this)">勇气</span><span class="tag-option" onclick="toggleTag(this)">友谊</span><span class="tag-option" onclick="toggleTag(this)">冒险</span><span class="tag-option" onclick="toggleTag(this)">分享</span><span class="tag-option" onclick="toggleTag(this)">成长</span></div></div>
<div class="form-group"><label class="form-label">额外要求(可选)</label><textarea class="form-input" placeholder="任何特殊要求..."></textarea></div>
</div>
<div class="modal-footer"><button class="btn-ghost" onclick="closeModal()">取消</button><button class="btn-primary">开始创作 ✨</button></div>
</div>
</div>
<script>
// 生成星星
const starsContainer = document.getElementById('stars');
for (let i = 0; i < 50; i++) {
const star = document.createElement('div');
star.className = 'star';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
star.style.animationDelay = Math.random() * 3 + 's';
star.style.animationDuration = (2 + Math.random() * 2) + 's';
starsContainer.appendChild(star);
}
// 滚动动画
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) entry.target.classList.add('visible');
});
}, { threshold: 0.15 });
document.querySelectorAll('.fade-in, .fade-in-left, .fade-in-right, .fade-in-scale').forEach(el => observer.observe(el));
// 数字计数动画
function easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }
function animateCounter(el, target, duration = 2000) {
const start = performance.now();
function update(now) {
const progress = Math.min((now - start) / duration, 1);
el.textContent = Math.floor(target * easeOutQuart(progress)).toLocaleString();
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
const counterObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.querySelectorAll('.counter').forEach((c, i) => {
setTimeout(() => animateCounter(c, parseInt(c.dataset.target)), i * 200);
});
obs.unobserve(entry.target);
}
});
}, { threshold: 0.3 });
document.querySelectorAll('#trust-bar').forEach(el => counterObserver.observe(el));
// FAQ 手风琴
function toggleFaq(btn) { btn.closest('.faq-item').classList.toggle('active'); }
// 模态框
let lastFocus = null;
function openModal() {
lastFocus = document.activeElement;
document.getElementById('createModal').classList.add('active');
document.body.style.overflow = 'hidden';
setTimeout(() => document.querySelector('.modal .form-input')?.focus(), 100);
}
function closeModal() {
document.getElementById('createModal').classList.remove('active');
document.body.style.overflow = '';
lastFocus?.focus();
}
function closeModalOnOverlay(e) { if (e.target.classList.contains('modal-overlay')) closeModal(); }
function toggleTag(el) { el.classList.toggle('selected'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
</script>
</body>
</html>

View File

@@ -1,28 +1,28 @@
const BASE_URL = ''
class ApiClient {
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '请求失败' }))
throw new Error(error.detail || '请求失败')
}
return response.json()
}
get<T>(url: string): Promise<T> {
return this.request<T>(url)
}
const BASE_URL = ''
class ApiClient {
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '请求失败' }))
throw new Error(error.detail || '请求失败')
}
return response.json()
}
get<T>(url: string): Promise<T> {
return this.request<T>(url)
}
post<T>(url: string, data?: unknown): Promise<T> {
return this.request<T>(url, {
method: 'POST',
@@ -41,5 +41,5 @@ class ApiClient {
return this.request<T>(url, { method: 'DELETE' })
}
}
export const api = new ApiClient()
export const api = new ApiClient()

View File

@@ -1,376 +1,376 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Component } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user'
import { useStorybookStore } from '../stores/storybook'
import { api } from '../api/client'
import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue'
import BaseTextarea from './ui/BaseTextarea.vue'
import {
SparklesIcon,
PencilSquareIcon,
BookOpenIcon,
PhotoIcon,
XMarkIcon,
ExclamationCircleIcon,
ShieldCheckIcon,
UserGroupIcon,
ShareIcon,
CheckBadgeIcon,
ArrowPathIcon,
HeartIcon
} from '@heroicons/vue/24/outline'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const storybookStore = useStorybookStore()
// State
const inputType = ref<'keywords' | 'full_story'>('keywords')
const outputMode = ref<'full_story' | 'storybook'>('full_story')
const inputData = ref('')
const educationTheme = ref('')
const loading = ref(false)
const error = ref('')
// Data
interface ChildProfile {
id: string
name: string
}
interface StoryUniverse {
id: string
name: string
}
const profiles = ref<ChildProfile[]>([])
const universes = ref<StoryUniverse[]>([])
const selectedProfileId = ref('')
const selectedUniverseId = ref('')
const profileError = ref('')
// Themes
type ThemeOption = { icon: Component; label: string; value: string }
const themes: ThemeOption[] = [
{ icon: ShieldCheckIcon, label: t('home.themeCourage'), value: '勇气' },
{ icon: UserGroupIcon, label: t('home.themeFriendship'), value: '友谊' },
{ icon: ShareIcon, label: t('home.themeSharing'), value: '分享' },
{ icon: CheckBadgeIcon, label: t('home.themeHonesty'), value: '诚实' },
{ icon: ArrowPathIcon, label: t('home.themePersistence'), value: '坚持' },
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
]
const profileOptions = computed(() =>
profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
)
const universeOptions = computed(() =>
universes.value.map(universe => ({ value: universe.id, label: universe.name })),
)
// Methods
function close() {
emit('update:modelValue', false)
error.value = ''
}
async function fetchProfiles() {
if (!userStore.user) return
profileError.value = ''
try {
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
profiles.value = data.profiles
if (!selectedProfileId.value && profiles.value.length > 0) {
selectedProfileId.value = profiles.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '档案加载失败'
}
}
async function fetchUniverses(profileId: string) {
selectedUniverseId.value = ''
if (!profileId) {
universes.value = []
return
}
try {
const data = await api.get<{ universes: StoryUniverse[] }>(`/api/profiles/${profileId}/universes`)
universes.value = data.universes
if (universes.value.length > 0) {
selectedUniverseId.value = universes.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '宇宙加载失败'
}
}
watch(selectedProfileId, (newId) => {
if (newId) fetchUniverses(newId)
})
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
fetchProfiles()
}
})
async function generateStory() {
if (!inputData.value.trim()) {
error.value = t('home.errorEmpty')
return
}
loading.value = true
error.value = ''
try {
const payload: Record<string, unknown> = {
type: inputType.value,
data: inputData.value,
education_theme: educationTheme.value || undefined,
}
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
if (outputMode.value === 'storybook') {
const response = await api.post<any>('/api/storybook/generate', {
keywords: inputData.value,
education_theme: educationTheme.value || undefined,
generate_images: true,
page_count: 6,
child_profile_id: selectedProfileId.value || undefined,
universe_id: selectedUniverseId.value || undefined
})
storybookStore.setStorybook(response)
close()
router.push('/storybook/view')
} else {
const result = await api.post<any>('/api/generate/full', payload)
const query: Record<string, string> = {}
if (result.errors && Object.keys(result.errors).length > 0) {
if (result.errors.image) query.imageError = '1'
}
close()
router.push({ path: `/story/${result.id}`, query })
}
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false
}
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close"
></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">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors z-10"
>
<XMarkIcon class="h-6 w-6 text-gray-400" />
</button>
<!-- 标题 -->
<h2 class="text-2xl font-bold text-gray-100 mb-6">
{{ t('home.createModalTitle') }}
</h2>
<!-- 输入类型切换 -->
<div class="flex space-x-3 mb-6">
<button
@click="inputType = 'keywords'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'keywords'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<SparklesIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeKeywords') }}</span>
</button>
<button
@click="inputType = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'full_story'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PencilSquareIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeStory') }}</span>
</button>
</div>
<!-- 呈现形式选择 (仅在关键词模式下可用) -->
<div class="flex space-x-3 mb-6" v-if="inputType === 'keywords'">
<button
@click="outputMode = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'full_story'
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<BookOpenIcon class="h-5 w-5" />
<span>普通故事</span>
</button>
<button
@click="outputMode = 'storybook'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'storybook'
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PhotoIcon class="h-5 w-5" />
<span>绘本模式</span>
</button>
</div>
<!-- 孩子档案选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.selectProfile') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.selectProfileOptional') }}</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<BaseSelect
v-model="selectedProfileId"
:options="[{ value: '', label: t('home.noProfile') }, ...profileOptions]"
/>
<BaseSelect
v-model="selectedUniverseId"
:options="[{ value: '', label: t('home.noUniverse') }, ...universeOptions]"
:disabled="!selectedProfileId || universes.length === 0"
/>
</div>
<div v-if="profileError" class="text-sm text-red-500 mt-2">{{ profileError }}</div>
<div v-if="selectedProfileId && universes.length === 0" class="text-sm text-gray-500 mt-2">
{{ t('home.noUniverseHint') }}
</div>
</div>
<!-- 输入区域 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ inputType === 'keywords' ? t('home.inputLabel') : t('home.inputLabelStory') }}
</label>
<BaseTextarea
v-model="inputData"
:placeholder="inputType === 'keywords' ? t('home.inputPlaceholder') : t('home.inputPlaceholderStory')"
:rows="5"
:maxLength="5000"
/>
</div>
<!-- 教育主题选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.themeLabel') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.themeOptional') }}</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="theme in themes"
:key="theme.value"
@click="educationTheme = educationTheme === theme.value ? '' : theme.value"
:class="[
'px-4 py-2 rounded-lg font-medium transition-all duration-300 flex items-center space-x-1.5 text-sm',
educationTheme === theme.value
? 'bg-gradient-to-r from-[#FFD369] to-[#FF9F43] text-[#0D0F1A] shadow-md'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<component :is="theme.icon" class="h-4 w-4" />
<span>{{ theme.label }}</span>
</button>
<BaseInput
v-model="educationTheme"
:placeholder="t('home.themeCustom')"
class="w-28"
/>
</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"
leave-active-class="transition-all duration-300"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="error" class="mb-4 p-3 bg-red-900/20 border border-red-800 text-red-400 rounded-xl flex items-center space-x-2">
<ExclamationCircleIcon class="h-5 w-5 flex-shrink-0" />
<span>{{ error }}</span>
</div>
</Transition>
<!-- 提交按钮 -->
<BaseButton
class="w-full"
size="lg"
:loading="loading"
:disabled="loading"
@click="generateStory"
>
<template v-if="loading">
{{ t('home.generating') }}
</template>
<template v-else>
<SparklesIcon class="h-5 w-5 mr-2" />
{{ t('home.startCreate') }}
</template>
</BaseButton>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* 临时添加一些 btn-magic 样式确保兼容 */
.btn-magic {
background: linear-gradient(135deg, #FFD369 0%, #FF9F43 100%);
color: #0D0F1A;
}
</style>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Component } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user'
import { useStorybookStore } from '../stores/storybook'
import { api } from '../api/client'
import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue'
import BaseTextarea from './ui/BaseTextarea.vue'
import {
SparklesIcon,
PencilSquareIcon,
BookOpenIcon,
PhotoIcon,
XMarkIcon,
ExclamationCircleIcon,
ShieldCheckIcon,
UserGroupIcon,
ShareIcon,
CheckBadgeIcon,
ArrowPathIcon,
HeartIcon
} from '@heroicons/vue/24/outline'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const storybookStore = useStorybookStore()
// State
const inputType = ref<'keywords' | 'full_story'>('keywords')
const outputMode = ref<'full_story' | 'storybook'>('full_story')
const inputData = ref('')
const educationTheme = ref('')
const loading = ref(false)
const error = ref('')
// Data
interface ChildProfile {
id: string
name: string
}
interface StoryUniverse {
id: string
name: string
}
const profiles = ref<ChildProfile[]>([])
const universes = ref<StoryUniverse[]>([])
const selectedProfileId = ref('')
const selectedUniverseId = ref('')
const profileError = ref('')
// Themes
type ThemeOption = { icon: Component; label: string; value: string }
const themes: ThemeOption[] = [
{ icon: ShieldCheckIcon, label: t('home.themeCourage'), value: '勇气' },
{ icon: UserGroupIcon, label: t('home.themeFriendship'), value: '友谊' },
{ icon: ShareIcon, label: t('home.themeSharing'), value: '分享' },
{ icon: CheckBadgeIcon, label: t('home.themeHonesty'), value: '诚实' },
{ icon: ArrowPathIcon, label: t('home.themePersistence'), value: '坚持' },
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
]
const profileOptions = computed(() =>
profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
)
const universeOptions = computed(() =>
universes.value.map(universe => ({ value: universe.id, label: universe.name })),
)
// Methods
function close() {
emit('update:modelValue', false)
error.value = ''
}
async function fetchProfiles() {
if (!userStore.user) return
profileError.value = ''
try {
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
profiles.value = data.profiles
if (!selectedProfileId.value && profiles.value.length > 0) {
selectedProfileId.value = profiles.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '档案加载失败'
}
}
async function fetchUniverses(profileId: string) {
selectedUniverseId.value = ''
if (!profileId) {
universes.value = []
return
}
try {
const data = await api.get<{ universes: StoryUniverse[] }>(`/api/profiles/${profileId}/universes`)
universes.value = data.universes
if (universes.value.length > 0) {
selectedUniverseId.value = universes.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '宇宙加载失败'
}
}
watch(selectedProfileId, (newId) => {
if (newId) fetchUniverses(newId)
})
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
fetchProfiles()
}
})
async function generateStory() {
if (!inputData.value.trim()) {
error.value = t('home.errorEmpty')
return
}
loading.value = true
error.value = ''
try {
const payload: Record<string, unknown> = {
type: inputType.value,
data: inputData.value,
education_theme: educationTheme.value || undefined,
}
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
if (outputMode.value === 'storybook') {
const response = await api.post<any>('/api/storybook/generate', {
keywords: inputData.value,
education_theme: educationTheme.value || undefined,
generate_images: true,
page_count: 6,
child_profile_id: selectedProfileId.value || undefined,
universe_id: selectedUniverseId.value || undefined
})
storybookStore.setStorybook(response)
close()
router.push('/storybook/view')
} else {
const result = await api.post<any>('/api/generate/full', payload)
const query: Record<string, string> = {}
if (result.errors && Object.keys(result.errors).length > 0) {
if (result.errors.image) query.imageError = '1'
}
close()
router.push({ path: `/story/${result.id}`, query })
}
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false
}
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close"
></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">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors z-10"
>
<XMarkIcon class="h-6 w-6 text-gray-400" />
</button>
<!-- 标题 -->
<h2 class="text-2xl font-bold text-gray-100 mb-6">
{{ t('home.createModalTitle') }}
</h2>
<!-- 输入类型切换 -->
<div class="flex space-x-3 mb-6">
<button
@click="inputType = 'keywords'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'keywords'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<SparklesIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeKeywords') }}</span>
</button>
<button
@click="inputType = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'full_story'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PencilSquareIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeStory') }}</span>
</button>
</div>
<!-- 呈现形式选择 (仅在关键词模式下可用) -->
<div class="flex space-x-3 mb-6" v-if="inputType === 'keywords'">
<button
@click="outputMode = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'full_story'
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<BookOpenIcon class="h-5 w-5" />
<span>普通故事</span>
</button>
<button
@click="outputMode = 'storybook'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'storybook'
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PhotoIcon class="h-5 w-5" />
<span>绘本模式</span>
</button>
</div>
<!-- 孩子档案选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.selectProfile') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.selectProfileOptional') }}</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<BaseSelect
v-model="selectedProfileId"
:options="[{ value: '', label: t('home.noProfile') }, ...profileOptions]"
/>
<BaseSelect
v-model="selectedUniverseId"
:options="[{ value: '', label: t('home.noUniverse') }, ...universeOptions]"
:disabled="!selectedProfileId || universes.length === 0"
/>
</div>
<div v-if="profileError" class="text-sm text-red-500 mt-2">{{ profileError }}</div>
<div v-if="selectedProfileId && universes.length === 0" class="text-sm text-gray-500 mt-2">
{{ t('home.noUniverseHint') }}
</div>
</div>
<!-- 输入区域 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ inputType === 'keywords' ? t('home.inputLabel') : t('home.inputLabelStory') }}
</label>
<BaseTextarea
v-model="inputData"
:placeholder="inputType === 'keywords' ? t('home.inputPlaceholder') : t('home.inputPlaceholderStory')"
:rows="5"
:maxLength="5000"
/>
</div>
<!-- 教育主题选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.themeLabel') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.themeOptional') }}</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="theme in themes"
:key="theme.value"
@click="educationTheme = educationTheme === theme.value ? '' : theme.value"
:class="[
'px-4 py-2 rounded-lg font-medium transition-all duration-300 flex items-center space-x-1.5 text-sm',
educationTheme === theme.value
? 'bg-gradient-to-r from-[#FFD369] to-[#FF9F43] text-[#0D0F1A] shadow-md'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<component :is="theme.icon" class="h-4 w-4" />
<span>{{ theme.label }}</span>
</button>
<BaseInput
v-model="educationTheme"
:placeholder="t('home.themeCustom')"
class="w-28"
/>
</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"
leave-active-class="transition-all duration-300"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="error" class="mb-4 p-3 bg-red-900/20 border border-red-800 text-red-400 rounded-xl flex items-center space-x-2">
<ExclamationCircleIcon class="h-5 w-5 flex-shrink-0" />
<span>{{ error }}</span>
</div>
</Transition>
<!-- 提交按钮 -->
<BaseButton
class="w-full"
size="lg"
:loading="loading"
:disabled="loading"
@click="generateStory"
>
<template v-if="loading">
{{ t('home.generating') }}
</template>
<template v-else>
<SparklesIcon class="h-5 w-5 mr-2" />
{{ t('home.startCreate') }}
</template>
</BaseButton>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* 临时添加一些 btn-magic 样式确保兼容 */
.btn-magic {
background: linear-gradient(135deg, #FFD369 0%, #FF9F43 100%);
color: #0D0F1A;
}
</style>

View File

@@ -84,4 +84,4 @@ function handleClick(event: MouseEvent) {
<component v-else-if="props.icon" :is="props.icon" class="h-5 w-5" aria-hidden="true" />
<slot />
</component>
</template>
</template>

View File

@@ -40,4 +40,4 @@ const baseClasses = computed(() => [
<div :class="[baseClasses, attrs.class]" v-bind="attrs">
<slot />
</div>
</template>
</template>

View File

@@ -68,4 +68,4 @@ const passthroughAttrs = computed(() => {
{{ props.error }}
</p>
</div>
</template>
</template>

View File

@@ -64,4 +64,4 @@ function handleChange(event: Event) {
</option>
</select>
</div>
</template>
</template>

View File

@@ -59,4 +59,4 @@ const passthroughAttrs = computed(() => {
</div>
</div>
</div>
</template>
</template>

View File

@@ -57,4 +57,4 @@ const headerClasses = computed(() => {
</div>
</div>
</Transition>
</template>
</template>

View File

@@ -42,4 +42,4 @@ function handleAction() {
{{ props.actionText }}
</BaseButton>
</div>
</template>
</template>

View File

@@ -33,4 +33,4 @@ const sizeClasses = computed(() => {
{{ props.text }}
</p>
</div>
</template>
</template>

View File

@@ -1,136 +1,136 @@
<script setup lang="ts">
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', false)
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
function loginWithDev() {
window.location.href = '/auth/dev/signin'
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close"
></div>
<!-- 对话框 -->
<div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors"
>
<XMarkIcon class="h-5 w-5 text-[var(--text-muted)]" />
</button>
<!-- 标题 -->
<div class="text-center mb-8">
<div class="text-3xl mb-3"></div>
<h2 class="text-xl font-bold text-[var(--text)] mb-2">
登录开始创作
</h2>
<p class="text-sm text-[var(--text-muted)]">
选择你的登录方式
</p>
</div>
<!-- 登录按钮 -->
<div class="space-y-3">
<button
@click="loginWithDev"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300 bg-gray-700/50 hover:bg-gray-700 text-white border-dashed border-gray-600"
>
<CommandLineIcon class="w-5 h-5" />
<span>开发模式一键登录</span>
</button>
<button
@click="loginWithGithub"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>使用 GitHub 登录</span>
</button>
<button
@click="loginWithGoogle"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span>使用 Google 登录</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.login-dialog {
--bg-deep: #0D0F1A;
--bg-card: #151829;
--bg-elevated: #1C2035;
--accent: #FFD369;
--text: #EAEAEA;
--text-muted: #6B7280;
--border: rgba(255,255,255,0.08);
}
.glass {
background: var(--bg-card);
border: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.login-btn {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text);
}
.login-btn:hover {
border-color: var(--accent);
background: rgba(255, 211, 105, 0.1);
}
</style>
<script setup lang="ts">
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', false)
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
function loginWithDev() {
window.location.href = '/auth/dev/signin'
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close"
></div>
<!-- 对话框 -->
<div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors"
>
<XMarkIcon class="h-5 w-5 text-[var(--text-muted)]" />
</button>
<!-- 标题 -->
<div class="text-center mb-8">
<div class="text-3xl mb-3"></div>
<h2 class="text-xl font-bold text-[var(--text)] mb-2">
登录开始创作
</h2>
<p class="text-sm text-[var(--text-muted)]">
选择你的登录方式
</p>
</div>
<!-- 登录按钮 -->
<div class="space-y-3">
<button
@click="loginWithDev"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300 bg-gray-700/50 hover:bg-gray-700 text-white border-dashed border-gray-600"
>
<CommandLineIcon class="w-5 h-5" />
<span>开发模式一键登录</span>
</button>
<button
@click="loginWithGithub"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>使用 GitHub 登录</span>
</button>
<button
@click="loginWithGoogle"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span>使用 Google 登录</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.login-dialog {
--bg-deep: #0D0F1A;
--bg-card: #151829;
--bg-elevated: #1C2035;
--accent: #FFD369;
--text: #EAEAEA;
--text-muted: #6B7280;
--border: rgba(255,255,255,0.08);
}
.glass {
background: var(--bg-card);
border: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.login-btn {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text);
}
.login-btn:hover {
border-color: var(--accent);
background: rgba(255, 211, 105, 0.1);
}
</style>

View File

@@ -5,4 +5,4 @@ export { default as BaseSelect } from './BaseSelect.vue'
export { default as BaseTextarea } from './BaseTextarea.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'

View File

@@ -1,38 +1,38 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface StorybookPage {
page_number: number
text: string
image_prompt: string
image_url?: string
}
export interface Storybook {
id?: number // 新增
title: string
main_character: string
art_style: string
pages: StorybookPage[]
cover_prompt: string
cover_url?: string
}
export const useStorybookStore = defineStore('storybook', () => {
const currentStorybook = ref<Storybook | null>(null)
function setStorybook(storybook: Storybook) {
currentStorybook.value = storybook
}
function clearStorybook() {
currentStorybook.value = null
}
return {
currentStorybook,
setStorybook,
clearStorybook,
}
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface StorybookPage {
page_number: number
text: string
image_prompt: string
image_url?: string
}
export interface Storybook {
id?: number // 新增
title: string
main_character: string
art_style: string
pages: StorybookPage[]
cover_prompt: string
cover_url?: string
}
export const useStorybookStore = defineStore('storybook', () => {
const currentStorybook = ref<Storybook | null>(null)
function setStorybook(storybook: Storybook) {
currentStorybook.value = storybook
}
function clearStorybook() {
currentStorybook.value = null
}
return {
currentStorybook,
setStorybook,
clearStorybook,
}
})

View File

@@ -1,49 +1,49 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../api/client'
interface User {
id: string
name: string
avatar_url: string | null
provider: string
}
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const loading = ref(false)
async function fetchSession() {
loading.value = true
try {
const data = await api.get<{ user: User | null }>('/auth/session')
user.value = data.user
} catch {
user.value = null
} finally {
loading.value = false
}
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
async function logout() {
await api.post('/auth/signout')
user.value = null
}
return {
user,
loading,
fetchSession,
loginWithGithub,
loginWithGoogle,
logout,
}
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../api/client'
interface User {
id: string
name: string
avatar_url: string | null
provider: string
}
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const loading = ref(false)
async function fetchSession() {
loading.value = true
try {
const data = await api.get<{ user: User | null }>('/auth/session')
user.value = data.user
} catch {
user.value = null
} finally {
loading.value = false
}
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
async function logout() {
await api.post('/auth/signout')
user.value = null
}
return {
user,
loading,
fetchSession,
loginWithGithub,
loginWithGoogle,
logout,
}
})

View File

@@ -1,221 +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>
<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

@@ -171,4 +171,4 @@ onMounted(fetchProfiles)
</router-link>
</div>
</div>
</template>
</template>

View File

@@ -309,4 +309,4 @@ onUnmounted(() => {
@cancel="showDeleteConfirm = false"
/>
</div>
</template>
</template>

View File

@@ -1,197 +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>
<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

@@ -205,4 +205,4 @@ onMounted(fetchUniverse)
</BaseCard>
</div>
</div>
</template>
</template>

View File

@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -1,4 +1,4 @@
/** @type {import('tailwindcss').Config} */
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [

View File

@@ -1,24 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,11 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,33 +1,33 @@
FROM python:3.11-slim
WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# 安装系统工具 (curl用于可能的健康检查)
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# 1. 缓存层:仅复制依赖定义并安装
# 创建伪造的 app 目录以满足 pip install . 的要求
COPY pyproject.toml .
RUN mkdir app && touch app/__init__.py
RUN pip install --no-cache-dir .
# 2. 源码层:复制真实代码
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini .
# 再次安装本身(不带依赖),确保源码更新被标记为已安装
RUN pip install --no-cache-dir --no-deps .
# 创建静态文件目录
RUN mkdir -p static/images
# 暴露端口
EXPOSE 8000
# 默认启动命令(可被 docker-compose 覆盖)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
FROM python:3.11-slim
WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# 安装系统工具 (curl用于可能的健康检查)
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# 1. 缓存层:仅复制依赖定义并安装
# 创建伪造的 app 目录以满足 pip install . 的要求
COPY pyproject.toml .
RUN mkdir app && touch app/__init__.py
RUN pip install --no-cache-dir .
# 2. 源码层:复制真实代码
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini .
# 再次安装本身(不带依赖),确保源码更新被标记为已安装
RUN pip install --no-cache-dir --no-deps .
# 创建静态文件目录
RUN mkdir -p static/images
# 暴露端口
EXPOSE 8000
# 默认启动命令(可被 docker-compose 覆盖)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,29 +1,29 @@
"""add api_key to providers
Revision ID: 0002_add_api_key_to_providers
Revises: 0001_init_providers_and_story_mode
Create Date: 2025-01-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0002_add_api_key"
down_revision = "0001_init_providers"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 添加 api_key 列,可为空,优先于 config_ref 使用
with op.batch_alter_table("providers", schema=None) as batch_op:
batch_op.add_column(
sa.Column("api_key", sa.String(length=500), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table("providers", schema=None) as batch_op:
batch_op.drop_column("api_key")
"""add api_key to providers
Revision ID: 0002_add_api_key_to_providers
Revises: 0001_init_providers_and_story_mode
Create Date: 2025-01-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0002_add_api_key"
down_revision = "0001_init_providers"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 添加 api_key 列,可为空,优先于 config_ref 使用
with op.batch_alter_table("providers", schema=None) as batch_op:
batch_op.add_column(
sa.Column("api_key", sa.String(length=500), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table("providers", schema=None) as batch_op:
batch_op.drop_column("api_key")

View File

@@ -1,100 +1,100 @@
"""add provider monitoring tables
Revision ID: 0003_add_monitoring
Revises: 0002_add_api_key
Create Date: 2025-01-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0003_add_monitoring"
down_revision = "0002_add_api_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 创建 provider_metrics 表
op.create_table(
"provider_metrics",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("provider_id", sa.String(length=36), nullable=False),
sa.Column(
"timestamp",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("cost_usd", sa.Numeric(precision=10, scale=6), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("request_id", sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["providers.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_provider_metrics_provider_id",
"provider_metrics",
["provider_id"],
unique=False,
)
op.create_index(
"ix_provider_metrics_timestamp",
"provider_metrics",
["timestamp"],
unique=False,
)
# 创建 provider_health 表
op.create_table(
"provider_health",
sa.Column("provider_id", sa.String(length=36), nullable=False),
sa.Column("is_healthy", sa.Boolean(), server_default=sa.text("true"), nullable=True),
sa.Column("last_check", sa.DateTime(timezone=True), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), server_default=sa.text("0"), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["providers.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("provider_id"),
)
# 创建 provider_secrets 表
op.create_table(
"provider_secrets",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("encrypted_value", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
op.drop_table("provider_secrets")
op.drop_table("provider_health")
op.drop_index("ix_provider_metrics_timestamp", table_name="provider_metrics")
op.drop_index("ix_provider_metrics_provider_id", table_name="provider_metrics")
op.drop_table("provider_metrics")
"""add provider monitoring tables
Revision ID: 0003_add_monitoring
Revises: 0002_add_api_key
Create Date: 2025-01-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0003_add_monitoring"
down_revision = "0002_add_api_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 创建 provider_metrics 表
op.create_table(
"provider_metrics",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("provider_id", sa.String(length=36), nullable=False),
sa.Column(
"timestamp",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("cost_usd", sa.Numeric(precision=10, scale=6), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("request_id", sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["providers.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_provider_metrics_provider_id",
"provider_metrics",
["provider_id"],
unique=False,
)
op.create_index(
"ix_provider_metrics_timestamp",
"provider_metrics",
["timestamp"],
unique=False,
)
# 创建 provider_health 表
op.create_table(
"provider_health",
sa.Column("provider_id", sa.String(length=36), nullable=False),
sa.Column("is_healthy", sa.Boolean(), server_default=sa.text("true"), nullable=True),
sa.Column("last_check", sa.DateTime(timezone=True), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), server_default=sa.text("0"), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["providers.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("provider_id"),
)
# 创建 provider_secrets 表
op.create_table(
"provider_secrets",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("encrypted_value", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
op.drop_table("provider_secrets")
op.drop_table("provider_health")
op.drop_index("ix_provider_metrics_timestamp", table_name="provider_metrics")
op.drop_index("ix_provider_metrics_provider_id", table_name="provider_metrics")
op.drop_table("provider_metrics")

View File

@@ -39,4 +39,4 @@ def upgrade():
def downgrade():
op.drop_index("idx_child_profiles_user_id", table_name="child_profiles")
op.drop_table("child_profiles")
op.drop_table("child_profiles")

View File

@@ -64,4 +64,4 @@ def downgrade():
op.drop_index("idx_story_universes_updated_at", table_name="story_universes")
op.drop_index("idx_story_universes_child_id", table_name="story_universes")
op.drop_table("story_universes")
op.drop_table("story_universes")

View File

@@ -75,4 +75,4 @@ def downgrade():
op.drop_index("idx_reading_events_created", table_name="reading_events")
op.drop_index("idx_reading_events_story", table_name="reading_events")
op.drop_index("idx_reading_events_profile", table_name="reading_events")
op.drop_table("reading_events")
op.drop_table("reading_events")

View File

@@ -1,25 +1,25 @@
"""add pages column to stories
Revision ID: 0008_add_pages_to_stories
Revises: 0007_add_push_configs_and_events
Create Date: 2026-01-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0008_add_pages_to_stories'
down_revision = '0007_add_push_configs_and_events'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('stories', sa.Column('pages', postgresql.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('stories', 'pages')
"""add pages column to stories
Revision ID: 0008_add_pages_to_stories
Revises: 0007_add_push_configs_and_events
Create Date: 2026-01-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0008_add_pages_to_stories'
down_revision = '0007_add_push_configs_and_events'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('stories', sa.Column('pages', postgresql.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('stories', 'pages')

Some files were not shown because too many files have changed in this diff Show More