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
Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions
This commit is contained in:
@@ -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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -150,4 +150,4 @@
|
|||||||
|
|
||||||
## 推荐
|
## 推荐
|
||||||
|
|
||||||
建议 Web MVP 使用方案 A(Soft Aurora),兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。
|
建议 Web MVP 使用方案 A(Soft Aurora),兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -227,4 +227,4 @@
|
|||||||
- 组件做 Variant
|
- 组件做 Variant
|
||||||
- 全部使用 Auto Layout
|
- 全部使用 Auto Layout
|
||||||
- 1440/1200/1024/768 建立栅格
|
- 1440/1200/1024/768 建立栅格
|
||||||
- 状态页复制并标注
|
- 状态页复制并标注
|
||||||
|
|||||||
@@ -296,4 +296,4 @@
|
|||||||
|
|
||||||
- 设计系统库(色板、文字、组件)
|
- 设计系统库(色板、文字、组件)
|
||||||
- 全流程高保真页面
|
- 全流程高保真页面
|
||||||
- 原型链接(Figma 中生成)
|
- 原型链接(Figma 中生成)
|
||||||
|
|||||||
@@ -56,4 +56,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -71,4 +71,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -85,4 +85,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -55,4 +55,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -108,4 +108,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -30,4 +30,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -25,4 +25,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -81,4 +81,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,4 +17,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -75,4 +75,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -70,4 +70,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -61,4 +61,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -56,4 +56,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -71,4 +71,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -85,4 +85,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -55,4 +55,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -108,4 +108,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -30,4 +30,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -25,4 +25,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -81,4 +81,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,4 +17,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -75,4 +75,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -70,4 +70,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -61,4 +61,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -56,4 +56,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -71,4 +71,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -85,4 +85,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -55,4 +55,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -108,4 +108,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -30,4 +30,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -25,4 +25,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -81,4 +81,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,4 +17,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -75,4 +75,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -70,4 +70,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -61,4 +61,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
@@ -174,4 +174,4 @@
|
|||||||
- PRD 里的“记忆系统”完整章节
|
- PRD 里的“记忆系统”完整章节
|
||||||
- 数据模型(含字段 + 时序衰减)
|
- 数据模型(含字段 + 时序衰减)
|
||||||
- 交互与界面草案
|
- 交互与界面草案
|
||||||
- 后端实现拆解(任务清单 + 里程碑)
|
- 后端实现拆解(任务清单 + 里程碑)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -228,4 +228,4 @@ def downgrade():
|
|||||||
## 九、相关文档
|
## 九、相关文档
|
||||||
|
|
||||||
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)
|
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)
|
||||||
- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md)
|
- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md)
|
||||||
|
|||||||
@@ -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
@@ -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. **故事分享** - 自然增长引擎
|
||||||
|
|
||||||
是否需要我为这些功能生成详细的技术规格文档?
|
是否需要我为这些功能生成详细的技术规格文档?
|
||||||
|
|||||||
@@ -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: 覆盖率报告
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 确保无类型错误。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
378
.github/workflows/build.yml
vendored
378
.github/workflows/build.yml
vendored
@@ -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
88
.gitignore
vendored
@@ -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
134
AGENTS.md
Normal 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) |
|
||||||
34
admin-frontend/.gitignore
vendored
34
admin-frontend/.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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;"]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5254
admin-frontend/package-lock.json
generated
5254
admin-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -68,4 +68,4 @@ const passthroughAttrs = computed(() => {
|
|||||||
{{ props.error }}
|
{{ props.error }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -64,4 +64,4 @@ function handleChange(event: Event) {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -59,4 +59,4 @@ const passthroughAttrs = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -57,4 +57,4 @@ const headerClasses = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ function handleAction() {
|
|||||||
{{ props.actionText }}
|
{{ props.actionText }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ const sizeClasses = computed(() => {
|
|||||||
{{ props.text }}
|
{{ props.text }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -171,4 +171,4 @@ onMounted(fetchProfiles)
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -309,4 +309,4 @@ onUnmounted(() => {
|
|||||||
@cancel="showDeleteConfirm = false"
|
@cancel="showDeleteConfirm = false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -205,4 +205,4 @@ onMounted(fetchUniverse)
|
|||||||
</BaseCard>
|
</BaseCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
14
admin-frontend/src/vite-env.d.ts
vendored
14
admin-frontend/src/vite-env.d.ts
vendored
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -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" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user