Compare commits

...

2 Commits

Author SHA1 Message Date
b8d3cb4644 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
2026-04-17 18:58:11 +08:00
fea4ef012f docs: sync workflow progress and weekend handoff 2026-04-17 18:44:43 +08:00
187 changed files with 19558 additions and 17368 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

88
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,37 +1,37 @@
server { server {
listen 80; listen 80;
server_name localhost; server_name localhost;
# 静态文件服务 # 静态文件服务
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
# SPA 路由支持: 找不到文件时回退到 index.html # SPA 路由支持: 找不到文件时回退到 index.html
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# 反向代理: 将 /admin 请求转发给管理后端 # 反向代理: 将 /admin 请求转发给管理后端
location /admin/ { location /admin/ {
proxy_pass http://backend-admin:8001/admin/; proxy_pass http://backend-admin:8001/admin/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# 仍保留 /api 以备偶尔调用通用接口Auth等 # 仍保留 /api 以备偶尔调用通用接口Auth等
location /api/ { location /api/ {
proxy_pass http://backend-admin:8001/api/; proxy_pass http://backend-admin:8001/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
# 静态资源代理 # 静态资源代理
location /static/ { location /static/ {
proxy_pass http://backend-admin:8001/static/; proxy_pass http://backend-admin:8001/static/;
} }
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
root /usr/share/nginx/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", "name": "dreamweaver-admin-console",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 5174", "dev": "vite --port 5174",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@vueuse/core": "^11.0.0", "@vueuse/core": "^11.0.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-i18n": "^11.2.2", "vue-i18n": "^11.2.2",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
} }
} }

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 113 B

After

Width:  |  Height:  |  Size: 116 B

View File

@@ -1,399 +1,399 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh"> <html lang="zh">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>梦语织机 - AI 儿童故事创作</title> <title>梦语织机 - AI 儿童故事创作</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--bg-deep: #0D0F1A; --bg-deep: #0D0F1A;
--bg-card: #151829; --bg-card: #151829;
--bg-elevated: #1C2035; --bg-elevated: #1C2035;
--accent: #FFD369; --accent: #FFD369;
--accent-soft: #FFF0C9; --accent-soft: #FFF0C9;
--text: #EAEAEA; --text: #EAEAEA;
--text-secondary: #9CA3AF; --text-secondary: #9CA3AF;
--text-muted: #6B7280; --text-muted: #6B7280;
--border: rgba(255,255,255,0.08); --border: rgba(255,255,255,0.08);
--glow: rgba(255, 211, 105, 0.15); --glow: rgba(255, 211, 105, 0.15);
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: 'Noto Sans SC', -apple-system, sans-serif; font-family: 'Noto Sans SC', -apple-system, sans-serif;
background: var(--bg-deep); background: var(--bg-deep);
color: var(--text); color: var(--text);
line-height: 1.6; line-height: 1.6;
overflow-x: hidden; overflow-x: hidden;
} }
/* 动画背景 */ /* 动画背景 */
.animated-bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; } .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 { 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-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-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; } .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 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 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); } } @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%); } .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; } .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); } } @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; } 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; } .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 { 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 { color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; transition: color 0.2s; }
.nav-links a:hover { color: var(--text); } .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 { 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); } .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 { 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.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 { 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-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 { 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-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 { 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); } .fade-in-scale.visible { opacity: 1; transform: scale(1); }
.delay-1 { transition-delay: 0.1s; } .delay-1 { transition-delay: 0.1s; }
.delay-2 { transition-delay: 0.2s; } .delay-2 { transition-delay: 0.2s; }
.delay-3 { transition-delay: 0.3s; } .delay-3 { transition-delay: 0.3s; }
.delay-4 { transition-delay: 0.4s; } .delay-4 { transition-delay: 0.4s; }
.delay-5 { transition-delay: 0.5s; } .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 { 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-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 { 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); } .btn-ghost:hover { background: var(--bg-elevated); border-color: var(--text-muted); }
/* Hero */ /* 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 { 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-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 { 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-badge span { color: var(--accent); }
.hero-title { font-size: 3.5rem; font-weight: 700; line-height: 1.2; margin-bottom: 24px; } .hero-title { font-size: 3.5rem; font-weight: 700; line-height: 1.2; margin-bottom: 24px; }
.hero-title .highlight { color: var(--accent); } .hero-title .highlight { color: var(--accent); }
.hero-desc { font-size: 1.15rem; color: var(--text-secondary); line-height: 1.8; margin-bottom: 40px; } .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-buttons { display: flex; gap: 16px; margin-bottom: 56px; }
.hero-visual { flex: 1; display: flex; justify-content: flex-end; padding-left: 60px; } .hero-visual { flex: 1; display: flex; justify-content: flex-end; padding-left: 60px; }
.visual-container { position: relative; width: 440px; } .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); } .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 { 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::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; } .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); } } @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-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.card-title { font-size: 1.15rem; font-weight: 600; } .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-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-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; } .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); } .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; } .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 { 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); } .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 { 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-1 { top: 20px; left: -60px; }
.float-card-2 { bottom: 80px; right: -40px; animation-delay: 1s; } .float-card-2 { bottom: 80px; right: -40px; animation-delay: 1s; }
@keyframes floatCard { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-15px); } } @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-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); } .float-text { font-size: 0.85rem; color: var(--text-secondary); }
/* Trust Bar */ /* 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-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; } .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-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 { font-size: 2.5rem; font-weight: 700; color: var(--text); line-height: 1; margin-bottom: 8px; }
.stat-number .accent { color: var(--accent); } .stat-number .accent { color: var(--accent); }
.stat-label { font-size: 0.9rem; color: var(--text-muted); } .stat-label { font-size: 0.9rem; color: var(--text-muted); }
/* Section通用 */ /* Section通用 */
.section { padding: 120px 48px; position: relative; z-index: 1; } .section { padding: 120px 48px; position: relative; z-index: 1; }
.section-header { text-align: center; margin-bottom: 72px; } .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-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; } .section-title { font-size: 2.5rem; font-weight: 600; }
/* Features */ /* Features */
.features-container { max-width: 1200px; margin: 0 auto; } .features-container { max-width: 1200px; margin: 0 auto; }
.features-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; } .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 { 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-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-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-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; } .feature-desc { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
/* How It Works */ /* How It Works */
.steps-container { max-width: 800px; margin: 0 auto; } .steps-container { max-width: 800px; margin: 0 auto; }
.steps { display: flex; flex-direction: column; gap: 24px; } .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 { 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: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-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 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; } .step-content p { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
/* FAQ */ /* FAQ */
.faq-container { max-width: 700px; margin: 0 auto; } .faq-container { max-width: 700px; margin: 0 auto; }
.faq-list { display: flex; flex-direction: column; gap: 12px; } .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 { 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:hover { border-color: rgba(255,255,255,0.15); }
.faq-item.active { border-color: rgba(255, 211, 105, 0.3); } .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 { 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-question:hover { color: var(--accent); }
.faq-item.active .faq-question { color: var(--accent); } .faq-item.active .faq-question { color: var(--accent); }
.faq-icon { width: 24px; height: 24px; flex-shrink: 0; position: relative; } .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, .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::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-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-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-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-item.active .faq-answer-wrapper { grid-template-rows: 1fr; }
.faq-answer { overflow: hidden; } .faq-answer { overflow: hidden; }
.faq-answer-content { padding: 0 24px 20px; color: var(--text-secondary); line-height: 1.7; } .faq-answer-content { padding: 0 24px 20px; color: var(--text-secondary); line-height: 1.7; }
/* CTA */ /* CTA */
.cta { text-align: center; } .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-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-title { font-size: 2rem; font-weight: 600; margin-bottom: 16px; }
.cta-desc { font-size: 1rem; color: var(--text-secondary); margin-bottom: 32px; } .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 { 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-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 { 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-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-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-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 { 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-close:hover { background: rgba(255,255,255,0.1); color: var(--text); }
.modal-body { padding: 24px; } .modal-body { padding: 24px; }
.form-group { margin-bottom: 20px; } .form-group { margin-bottom: 20px; }
.form-label { display: block; font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 8px; } .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 { 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: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); } .form-input::placeholder { color: var(--text-muted); }
textarea.form-input { min-height: 100px; resize: vertical; } textarea.form-input { min-height: 100px; resize: vertical; }
.tags-select { display: flex; flex-wrap: wrap; gap: 8px; } .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 { 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: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); } .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; } .modal-footer { padding: 20px 24px; border-top: 1px solid var(--border); display: flex; gap: 12px; justify-content: flex-end; }
/* Footer */ /* Footer */
.footer { padding: 40px 48px; background: var(--bg-elevated); border-top: 1px solid var(--border); text-align: center; position: relative; z-index: 1; } .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; } .footer p { color: var(--text-muted); font-size: 0.9rem; }
/* 响应式 */ /* 响应式 */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.hero { flex-direction: column; padding: 100px 24px 60px; } .hero { flex-direction: column; padding: 100px 24px 60px; }
.hero-content { max-width: 100%; text-align: center; } .hero-content { max-width: 100%; text-align: center; }
.hero-title { font-size: 2.5rem; } .hero-title { font-size: 2.5rem; }
.hero-buttons { justify-content: center; } .hero-buttons { justify-content: center; }
.hero-visual { padding: 50px 0 0; } .hero-visual { padding: 50px 0 0; }
.visual-container { width: 100%; max-width: 400px; } .visual-container { width: 100%; max-width: 400px; }
.float-card { display: none; } .float-card { display: none; }
.features-grid { grid-template-columns: 1fr; } .features-grid { grid-template-columns: 1fr; }
nav { padding: 14px 20px; } nav { padding: 14px 20px; }
.nav-links { display: none; } .nav-links { display: none; }
.section { padding: 80px 24px; } .section { padding: 80px 24px; }
.trust-bar { padding: 40px 24px; } .trust-bar { padding: 40px 24px; }
.trust-container { gap: 40px; } .trust-container { gap: 40px; }
.step { flex-direction: column; text-align: center; } .step { flex-direction: column; text-align: center; }
.cta-container { padding: 40px 24px; } .cta-container { padding: 40px 24px; }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="animated-bg"> <div class="animated-bg">
<div class="glow glow-1"></div> <div class="glow glow-1"></div>
<div class="glow glow-2"></div> <div class="glow glow-2"></div>
<div class="glow glow-3"></div> <div class="glow glow-3"></div>
<div class="grid-bg"></div> <div class="grid-bg"></div>
<div class="stars" id="stars"></div> <div class="stars" id="stars"></div>
</div> </div>
<nav> <nav>
<div class="logo">梦语织机</div> <div class="logo">梦语织机</div>
<ul class="nav-links"> <ul class="nav-links">
<li><a href="#features">功能</a></li> <li><a href="#features">功能</a></li>
<li><a href="#how-it-works">使用方法</a></li> <li><a href="#how-it-works">使用方法</a></li>
<li><a href="#faq">常见问题</a></li> <li><a href="#faq">常见问题</a></li>
</ul> </ul>
<button class="nav-cta" onclick="openModal()">开始创作</button> <button class="nav-cta" onclick="openModal()">开始创作</button>
</nav> </nav>
<!-- Hero Section --> <!-- Hero Section -->
<section class="hero"> <section class="hero">
<div class="hero-content"> <div class="hero-content">
<div class="hero-badge fade-in"><span></span> 专为 3-8 岁儿童设计</div> <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> <h1 class="hero-title fade-in delay-1">为孩子编织<br><span class="highlight">专属的童话梦境</span></h1>
<p class="hero-desc fade-in delay-2">输入几个关键词AI 即刻为孩子创作独一无二的睡前故事。温暖的声音,精美的插画,让每个夜晚都充满想象。</p> <p class="hero-desc fade-in delay-2">输入几个关键词AI 即刻为孩子创作独一无二的睡前故事。温暖的声音,精美的插画,让每个夜晚都充满想象。</p>
<div class="hero-buttons fade-in delay-3"> <div class="hero-buttons fade-in delay-3">
<button class="btn-primary" onclick="openModal()">免费开始创作</button> <button class="btn-primary" onclick="openModal()">免费开始创作</button>
<button class="btn-ghost" onclick="document.getElementById('features').scrollIntoView({behavior:'smooth'})">了解更多</button> <button class="btn-ghost" onclick="document.getElementById('features').scrollIntoView({behavior:'smooth'})">了解更多</button>
</div> </div>
</div> </div>
<div class="hero-visual fade-in-right delay-2"> <div class="hero-visual fade-in-right delay-2">
<div class="visual-container"> <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="float-card float-card-1"><div class="float-icon">🎨</div><span class="float-text">AI 生成插画</span></div>
<div class="main-card"> <div class="main-card">
<div class="card-cover"><span class="card-cover-text">🐰</span></div> <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> <div class="card-header"><h3 class="card-title">小兔子的勇气冒险</h3><span class="card-badge">刚刚生成</span></div>
<p class="card-excerpt">在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔。今天,她决定独自去森林深处...</p> <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-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 class="card-actions"><button class="action-btn">🔊 播放朗读</button><button class="action-btn">🖼 生成插画</button></div>
</div> </div>
<div class="float-card float-card-2"><div class="float-icon">🔊</div><span class="float-text">温暖语音朗读</span></div> <div class="float-card float-card-2"><div class="float-icon">🔊</div><span class="float-text">温暖语音朗读</span></div>
</div> </div>
</div> </div>
</section> </section>
<!-- Trust Bar --> <!-- Trust Bar -->
<section class="trust-bar" id="trust-bar"> <section class="trust-bar" id="trust-bar">
<div class="trust-container"> <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"><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-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 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> </div>
</section> </section>
<!-- Features --> <!-- Features -->
<section class="section" id="features"> <section class="section" id="features">
<div class="features-container"> <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="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="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-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-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-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-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-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 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>
</div> </div>
</section> </section>
<!-- How It Works --> <!-- How It Works -->
<section class="section" id="how-it-works"> <section class="section" id="how-it-works">
<div class="steps-container"> <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="section-header"><p class="section-label fade-in">使用方法</p><h2 class="section-title fade-in delay-1">简单三步,创造专属故事</h2></div>
<div class="steps"> <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-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-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 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>
</div> </div>
</section> </section>
<!-- FAQ --> <!-- FAQ -->
<section class="section" id="faq"> <section class="section" id="faq">
<div class="faq-container"> <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="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-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-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-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-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 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>
</div> </div>
</section> </section>
<!-- CTA --> <!-- CTA -->
<section class="section cta"> <section class="section cta">
<div class="cta-container fade-in-scale"> <div class="cta-container fade-in-scale">
<h2 class="cta-title">准备好为孩子创造魔法了吗?</h2> <h2 class="cta-title">准备好为孩子创造魔法了吗?</h2>
<p class="cta-desc">免费开始,无需信用卡</p> <p class="cta-desc">免费开始,无需信用卡</p>
<button class="btn-primary" onclick="openModal()">立即开始创作</button> <button class="btn-primary" onclick="openModal()">立即开始创作</button>
</div> </div>
</section> </section>
<!-- Footer --> <!-- Footer -->
<footer class="footer"> <footer class="footer">
<p>© 2024 梦语织机 DreamWeaver. All rights reserved.</p> <p>© 2024 梦语织机 DreamWeaver. All rights reserved.</p>
</footer> </footer>
<!-- 创作模态框 --> <!-- 创作模态框 -->
<div class="modal-overlay" id="createModal" onclick="closeModalOnOverlay(event)"> <div class="modal-overlay" id="createModal" onclick="closeModalOnOverlay(event)">
<div class="modal"> <div class="modal">
<div class="modal-header"><h2 class="modal-title">创作新故事</h2><button class="modal-close" onclick="closeModal()">×</button></div> <div class="modal-header"><h2 class="modal-title">创作新故事</h2><button class="modal-close" onclick="closeModal()">×</button></div>
<div class="modal-body"> <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><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><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 class="form-group"><label class="form-label">额外要求(可选)</label><textarea class="form-input" placeholder="任何特殊要求..."></textarea></div>
</div> </div>
<div class="modal-footer"><button class="btn-ghost" onclick="closeModal()">取消</button><button class="btn-primary">开始创作 ✨</button></div> <div class="modal-footer"><button class="btn-ghost" onclick="closeModal()">取消</button><button class="btn-primary">开始创作 ✨</button></div>
</div> </div>
</div> </div>
<script> <script>
// 生成星星 // 生成星星
const starsContainer = document.getElementById('stars'); const starsContainer = document.getElementById('stars');
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
const star = document.createElement('div'); const star = document.createElement('div');
star.className = 'star'; star.className = 'star';
star.style.left = Math.random() * 100 + '%'; star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%'; star.style.top = Math.random() * 100 + '%';
star.style.animationDelay = Math.random() * 3 + 's'; star.style.animationDelay = Math.random() * 3 + 's';
star.style.animationDuration = (2 + Math.random() * 2) + 's'; star.style.animationDuration = (2 + Math.random() * 2) + 's';
starsContainer.appendChild(star); starsContainer.appendChild(star);
} }
// 滚动动画 // 滚动动画
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) entry.target.classList.add('visible'); if (entry.isIntersecting) entry.target.classList.add('visible');
}); });
}, { threshold: 0.15 }); }, { threshold: 0.15 });
document.querySelectorAll('.fade-in, .fade-in-left, .fade-in-right, .fade-in-scale').forEach(el => observer.observe(el)); 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 easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }
function animateCounter(el, target, duration = 2000) { function animateCounter(el, target, duration = 2000) {
const start = performance.now(); const start = performance.now();
function update(now) { function update(now) {
const progress = Math.min((now - start) / duration, 1); const progress = Math.min((now - start) / duration, 1);
el.textContent = Math.floor(target * easeOutQuart(progress)).toLocaleString(); el.textContent = Math.floor(target * easeOutQuart(progress)).toLocaleString();
if (progress < 1) requestAnimationFrame(update); if (progress < 1) requestAnimationFrame(update);
} }
requestAnimationFrame(update); requestAnimationFrame(update);
} }
const counterObserver = new IntersectionObserver((entries, obs) => { const counterObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
entry.target.querySelectorAll('.counter').forEach((c, i) => { entry.target.querySelectorAll('.counter').forEach((c, i) => {
setTimeout(() => animateCounter(c, parseInt(c.dataset.target)), i * 200); setTimeout(() => animateCounter(c, parseInt(c.dataset.target)), i * 200);
}); });
obs.unobserve(entry.target); obs.unobserve(entry.target);
} }
}); });
}, { threshold: 0.3 }); }, { threshold: 0.3 });
document.querySelectorAll('#trust-bar').forEach(el => counterObserver.observe(el)); document.querySelectorAll('#trust-bar').forEach(el => counterObserver.observe(el));
// FAQ 手风琴 // FAQ 手风琴
function toggleFaq(btn) { btn.closest('.faq-item').classList.toggle('active'); } function toggleFaq(btn) { btn.closest('.faq-item').classList.toggle('active'); }
// 模态框 // 模态框
let lastFocus = null; let lastFocus = null;
function openModal() { function openModal() {
lastFocus = document.activeElement; lastFocus = document.activeElement;
document.getElementById('createModal').classList.add('active'); document.getElementById('createModal').classList.add('active');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
setTimeout(() => document.querySelector('.modal .form-input')?.focus(), 100); setTimeout(() => document.querySelector('.modal .form-input')?.focus(), 100);
} }
function closeModal() { function closeModal() {
document.getElementById('createModal').classList.remove('active'); document.getElementById('createModal').classList.remove('active');
document.body.style.overflow = ''; document.body.style.overflow = '';
lastFocus?.focus(); lastFocus?.focus();
} }
function closeModalOnOverlay(e) { if (e.target.classList.contains('modal-overlay')) closeModal(); } function closeModalOnOverlay(e) { if (e.target.classList.contains('modal-overlay')) closeModal(); }
function toggleTag(el) { el.classList.toggle('selected'); } function toggleTag(el) { el.classList.toggle('selected'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
</script> </script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,136 +1,136 @@
<script setup lang="ts"> <script setup lang="ts">
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline' import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
defineProps<{ defineProps<{
modelValue: boolean modelValue: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
}>() }>()
function close() { function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function loginWithGithub() { function loginWithGithub() {
window.location.href = '/auth/github/signin' window.location.href = '/auth/github/signin'
} }
function loginWithGoogle() { function loginWithGoogle() {
window.location.href = '/auth/google/signin' window.location.href = '/auth/google/signin'
} }
function loginWithDev() { function loginWithDev() {
window.location.href = '/auth/dev/signin' window.location.href = '/auth/dev/signin'
} }
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition <Transition
enter-active-class="transition-opacity duration-300" enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0" enter-from-class="opacity-0"
enter-to-class="opacity-100" enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300" leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100" leave-from-class="opacity-100"
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<div <div
v-if="modelValue" v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
> >
<!-- 遮罩层 --> <!-- 遮罩层 -->
<div <div
class="absolute inset-0 bg-black/60 backdrop-blur-sm" class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close" @click="close"
></div> ></div>
<!-- 对话框 --> <!-- 对话框 -->
<div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative"> <div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative">
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<button <button
@click="close" @click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors" 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)]" /> <XMarkIcon class="h-5 w-5 text-[var(--text-muted)]" />
</button> </button>
<!-- 标题 --> <!-- 标题 -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="text-3xl mb-3"></div> <div class="text-3xl mb-3"></div>
<h2 class="text-xl font-bold text-[var(--text)] mb-2"> <h2 class="text-xl font-bold text-[var(--text)] mb-2">
登录开始创作 登录开始创作
</h2> </h2>
<p class="text-sm text-[var(--text-muted)]"> <p class="text-sm text-[var(--text-muted)]">
选择你的登录方式 选择你的登录方式
</p> </p>
</div> </div>
<!-- 登录按钮 --> <!-- 登录按钮 -->
<div class="space-y-3"> <div class="space-y-3">
<button <button
@click="loginWithDev" @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" 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" /> <CommandLineIcon class="w-5 h-5" />
<span>开发模式一键登录</span> <span>开发模式一键登录</span>
</button> </button>
<button <button
@click="loginWithGithub" @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" 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"> <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"/> <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> </svg>
<span>使用 GitHub 登录</span> <span>使用 GitHub 登录</span>
</button> </button>
<button <button
@click="loginWithGoogle" @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" 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"> <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="#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="#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="#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"/> <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> </svg>
<span>使用 Google 登录</span> <span>使用 Google 登录</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
</template> </template>
<style scoped> <style scoped>
.login-dialog { .login-dialog {
--bg-deep: #0D0F1A; --bg-deep: #0D0F1A;
--bg-card: #151829; --bg-card: #151829;
--bg-elevated: #1C2035; --bg-elevated: #1C2035;
--accent: #FFD369; --accent: #FFD369;
--text: #EAEAEA; --text: #EAEAEA;
--text-muted: #6B7280; --text-muted: #6B7280;
--border: rgba(255,255,255,0.08); --border: rgba(255,255,255,0.08);
} }
.glass { .glass {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.login-btn { .login-btn {
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); color: var(--text);
} }
.login-btn:hover { .login-btn:hover {
border-color: var(--accent); border-color: var(--accent);
background: rgba(255, 211, 105, 0.1); background: rgba(255, 211, 105, 0.1);
} }
</style> </style>

View File

@@ -5,4 +5,4 @@ export { default as BaseSelect } from './BaseSelect.vue'
export { default as BaseTextarea } from './BaseTextarea.vue' export { default as BaseTextarea } from './BaseTextarea.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue' export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.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 { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
export interface StorybookPage { export interface StorybookPage {
page_number: number page_number: number
text: string text: string
image_prompt: string image_prompt: string
image_url?: string image_url?: string
} }
export interface Storybook { export interface Storybook {
id?: number // 新增 id?: number // 新增
title: string title: string
main_character: string main_character: string
art_style: string art_style: string
pages: StorybookPage[] pages: StorybookPage[]
cover_prompt: string cover_prompt: string
cover_url?: string cover_url?: string
} }
export const useStorybookStore = defineStore('storybook', () => { export const useStorybookStore = defineStore('storybook', () => {
const currentStorybook = ref<Storybook | null>(null) const currentStorybook = ref<Storybook | null>(null)
function setStorybook(storybook: Storybook) { function setStorybook(storybook: Storybook) {
currentStorybook.value = storybook currentStorybook.value = storybook
} }
function clearStorybook() { function clearStorybook() {
currentStorybook.value = null currentStorybook.value = null
} }
return { return {
currentStorybook, currentStorybook,
setStorybook, setStorybook,
clearStorybook, clearStorybook,
} }
}) })

View File

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

View File

@@ -1,221 +1,221 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client' import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue' import BaseButton from '../components/ui/BaseButton.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue' import EmptyState from '../components/ui/EmptyState.vue'
import { import {
SparklesIcon, SparklesIcon,
BookOpenIcon, BookOpenIcon,
TrophyIcon, TrophyIcon,
FlagIcon, FlagIcon,
CalendarIcon, CalendarIcon,
ChevronLeftIcon, ChevronLeftIcon,
ExclamationCircleIcon ExclamationCircleIcon
} from '@heroicons/vue/24/solid' } from '@heroicons/vue/24/solid'
interface TimelineEvent { interface TimelineEvent {
date: string date: string
type: 'story' | 'achievement' | 'milestone' type: 'story' | 'achievement' | 'milestone'
title: string title: string
description: string | null description: string | null
image_url: string | null image_url: string | null
metadata: { metadata: {
story_id?: number story_id?: number
mode?: string mode?: string
[key: string]: any [key: string]: any
} | null } | null
} }
interface TimelineResponse { interface TimelineResponse {
events: TimelineEvent[] events: TimelineEvent[]
} }
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const events = ref<TimelineEvent[]>([]) const events = ref<TimelineEvent[]>([])
const profileId = route.params.id as string const profileId = route.params.id as string
const profileName = ref('') // We might need to fetch profile details separately or store it const profileName = ref('') // We might need to fetch profile details separately or store it
function getIcon(type: string) { function getIcon(type: string) {
switch (type) { switch (type) {
case 'milestone': return FlagIcon case 'milestone': return FlagIcon
case 'story': return BookOpenIcon case 'story': return BookOpenIcon
case 'achievement': return TrophyIcon case 'achievement': return TrophyIcon
default: return SparklesIcon default: return SparklesIcon
} }
} }
function getColor(type: string) { function getColor(type: string) {
switch (type) { switch (type) {
case 'milestone': return 'text-blue-500' case 'milestone': return 'text-blue-500'
case 'story': return 'text-purple-500' case 'story': return 'text-purple-500'
case 'achievement': return 'text-yellow-500' case 'achievement': return 'text-yellow-500'
default: return 'text-gray-500' default: return 'text-gray-500'
} }
} }
function formatDate(isoStr: string) { function formatDate(isoStr: string) {
const date = new Date(isoStr) const date = new Date(isoStr)
return date.toLocaleDateString('zh-CN', { return date.toLocaleDateString('zh-CN', {
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric' year: 'numeric'
}) })
} }
async function fetchTimeline() { async function fetchTimeline() {
loading.value = true loading.value = true
try { try {
// Ideally we should also fetch profile basic info here or if the timeline endpoint included it // Ideally we should also fetch profile basic info here or if the timeline endpoint included it
// For now, let's just fetch timeline. // For now, let's just fetch timeline.
// Wait, let's fetch profile first to get the name // Wait, let's fetch profile first to get the name
const profile = await api.get<any>(`/api/profiles/${profileId}`) const profile = await api.get<any>(`/api/profiles/${profileId}`)
profileName.value = profile.name profileName.value = profile.name
const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`) const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`)
events.value = data.events events.value = data.events
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '加载失败' error.value = e instanceof Error ? e.message : '加载失败'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function handleEventClick(event: TimelineEvent) { function handleEventClick(event: TimelineEvent) {
if (event.type === 'story' && event.metadata?.story_id) { if (event.type === 'story' && event.metadata?.story_id) {
// Check mode // Check mode
if (event.metadata.mode === 'storybook') { if (event.metadata.mode === 'storybook') {
// 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。 // 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。
// 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。 // 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。
// 暂时先只支持跳转到普通故事详情,或者给出提示 // 暂时先只支持跳转到普通故事详情,或者给出提示
// TODO: Viewer support loading by ID // TODO: Viewer support loading by ID
router.push(`/story/${event.metadata.story_id}`) router.push(`/story/${event.metadata.story_id}`)
} else { } else {
router.push(`/story/${event.metadata.story_id}`) router.push(`/story/${event.metadata.story_id}`)
} }
} }
} }
onMounted(fetchTimeline) onMounted(fetchTimeline)
</script> </script>
<template> <template>
<div class="min-h-screen bg-slate-50 relative overflow-x-hidden font-sans"> <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="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 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 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>
<!-- 顶部导航 --> <!-- 顶部导航 -->
<div class="relative z-10 max-w-4xl mx-auto px-4 py-8"> <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"> <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" /> 返回档案 <ChevronLeftIcon class="h-4 w-4" /> 返回档案
</BaseButton> </BaseButton>
<div v-if="loading" class="py-20"> <div v-if="loading" class="py-20">
<LoadingSpinner text="正在追溯时光..." /> <LoadingSpinner text="正在追溯时光..." />
</div> </div>
<div v-else-if="error" class="py-20"> <div v-else-if="error" class="py-20">
<EmptyState <EmptyState
:icon="ExclamationCircleIcon" :icon="ExclamationCircleIcon"
title="出错了" title="出错了"
:description="error" :description="error"
/> />
</div> </div>
<template v-else> <template v-else>
<div class="text-center mb-16 animate-fade-in-down"> <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> <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> <p v-if="profileName" class="text-xl text-gray-600 font-medium"> {{ profileName }} 的奇妙冒险之旅 </p>
</div> </div>
<!-- 暂无数据 --> <!-- 暂无数据 -->
<div v-if="events.length === 0" class="text-center py-20 bg-white/50 backdrop-blur rounded-3xl border border-white"> <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" /> <SparklesIcon class="h-16 w-16 text-purple-300 mx-auto mb-4" />
<p class="text-xl text-gray-500">还没有开始冒险呢快去创作第一个故事吧</p> <p class="text-xl text-gray-500">还没有开始冒险呢快去创作第一个故事吧</p>
</div> </div>
<!-- 时间轴内容 --> <!-- 时间轴内容 -->
<div v-else class="relative pb-20"> <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 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" <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="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'" :class="index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'"
> >
<!-- 宽度占位 (Desktop) --> <!-- 宽度占位 (Desktop) -->
<div class="hidden md:block md:w-5/12"></div> <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"> <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)" /> <component :is="getIcon(event.type)" class="h-3 w-3 md:h-5 md:w-5" :class="getColor(event.type)" />
</div> </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="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 <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" 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)" @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="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'"> <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" /> <CalendarIcon class="h-4 w-4 text-purple-400" />
{{ formatDate(event.date) }} {{ formatDate(event.date) }}
</div> </div>
<h3 class="text-xl font-bold text-gray-800 mb-2 group-hover:text-purple-600 transition-colors">{{ event.title }}</h3> <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> <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"> <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" /> <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 class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
</div> </div>
<!-- Role Badge --> <!-- 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"> <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" /> 成就解锁 <TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.gradient-text { .gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600; @apply bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600;
} }
.animate-blob { .animate-blob {
animation: blob 7s infinite; animation: blob 7s infinite;
} }
.animation-delay-2000 { .animation-delay-2000 {
animation-delay: 2s; animation-delay: 2s;
} }
@keyframes blob { @keyframes blob {
0% { transform: translate(0px, 0px) scale(1); } 0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); } 33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); } 66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); } 100% { transform: translate(0px, 0px) scale(1); }
} }
.animate-fade-in-down { .animate-fade-in-down {
animation: fadeInDown 0.8s ease-out; animation: fadeInDown 0.8s ease-out;
} }
@keyframes fadeInDown { @keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); } from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,4 +39,4 @@ def upgrade():
def downgrade(): def downgrade():
op.drop_index("idx_child_profiles_user_id", table_name="child_profiles") 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_updated_at", table_name="story_universes")
op.drop_index("idx_story_universes_child_id", 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_created", table_name="reading_events")
op.drop_index("idx_reading_events_story", 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_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 """add pages column to stories
Revision ID: 0008_add_pages_to_stories Revision ID: 0008_add_pages_to_stories
Revises: 0007_add_push_configs_and_events Revises: 0007_add_push_configs_and_events
Create Date: 2026-01-20 Create Date: 2026-01-20
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '0008_add_pages_to_stories' revision = '0008_add_pages_to_stories'
down_revision = '0007_add_push_configs_and_events' down_revision = '0007_add_push_configs_and_events'
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.add_column('stories', sa.Column('pages', postgresql.JSON(), nullable=True)) op.add_column('stories', sa.Column('pages', postgresql.JSON(), nullable=True))
def downgrade() -> None: def downgrade() -> None:
op.drop_column('stories', 'pages') op.drop_column('stories', 'pages')

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