Initial commit: clean project structure

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

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

View File

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

View File

@@ -0,0 +1,153 @@
# DreamWeaver 品牌视觉方向Web 阶段)
## 概述
提供三套高保真视觉方向,用于 Web MVP。三者的 UX 结构一致,仅在色彩、视觉重量与插画风格上不同。
---
## 方案 ASoft Aurora温暖高级
**理由**
- 家长信任感强,同时保留童趣与想象力。
- 高级但不商业化。
**配色**
- 主色 600: #6C5CE7
- 主色 500: #7C69FF
- 主色 100: #EAE7FF
- 强调粉: #FF8FB1
- 强调蓝: #65C3FF
- 中性 900: #1F2430
- 中性 700: #4B5563
- 中性 500: #9AA3B2
- 中性 200: #E5E7EB
- 中性 100: #F5F7FB
- 白色: #FFFFFF
**渐变**
- Hero 背景linear-gradient(135deg, #EAE7FF 0%, #FDF6FF 40%, #EAF6FF 100%)
- CTA 光晕radial-gradient(circle at 30% 30%, #7C69FF 0%, #6C5CE7 50%, #4C3FCF 100%)
**字体**
- 标题Noto Sans SC / Inter
- 正文Noto Sans SC / Inter
- 数字强调Inter
**插画风格**
- 柔和、低对比度、轻画笔质感。
- 圆润形状、轻高光。
- 角色简单轮廓与友好表情。
**图标风格**
- 1.5px 线宽,圆角端点。
- 强调色点缀,避免过度饱和。
**组件建议**
- 按钮:主色实心 + 内阴影。
- 卡片:大圆角 + 柔和阴影。
- 输入:浅底色 + 主色焦点环。
---
## 方案 BStorybook Minimal极简编辑风
**理由**
- 强可读性,适合长文本阅读。
- 简洁、专业、强调内容。
**配色**
- 主色 600: #3B82F6
- 主色 500: #60A5FA
- 主色 100: #DBEAFE
- 强调金: #F5C542
- 强调薄荷: #6EE7B7
- 中性 900: #111827
- 中性 700: #374151
- 中性 500: #9CA3AF
- 中性 200: #E5E7EB
- 中性 100: #F9FAFB
- 白色: #FFFFFF
**渐变**
- Hero 背景linear-gradient(180deg, #F9FAFB 0%, #EEF2FF 100%)
- CTA 光晕radial-gradient(circle at 40% 30%, #60A5FA 0%, #3B82F6 60%, #1D4ED8 100%)
**字体**
- 标题Inter / Noto Sans SC
- 正文Inter / Noto Sans SC
- 阅读场景可提升行高和对比度。
**插画风格**
- 扁平化、线条干净、留白较多。
- 色彩克制、视觉清爽。
**图标风格**
- 2px 线宽,极简。
**组件建议**
- 按钮:纯色、无明显渐变。
- 卡片:细边框 + 极轻阴影。
- 输入:白底 + 清晰边框。
---
## 方案 CPlayful Glow活力明快
**理由**
- 视觉更鲜活,记忆点强。
- 更偏童趣,但仍保持专业感。
**配色**
- 主色 600: #7C3AED
- 主色 500: #8B5CF6
- 主色 100: #EDE9FE
- 强调珊瑚: #FB7185
- 强调青蓝: #22D3EE
- 中性 900: #1F2937
- 中性 700: #4B5563
- 中性 500: #9CA3AF
- 中性 200: #E5E7EB
- 中性 100: #F5F5F7
- 白色: #FFFFFF
**渐变**
- Hero 背景linear-gradient(135deg, #EDE9FE 0%, #FFE4F3 45%, #E0F7FF 100%)
- CTA 光晕radial-gradient(circle at 30% 30%, #8B5CF6 0%, #7C3AED 60%, #5B21B6 100%)
**字体**
- 标题Noto Sans SC / Inter
- 正文Noto Sans SC / Inter
- 强调色点到为止,避免花哨。
**插画风格**
- 更鲜艳、更活泼。
- 大色块 + 轻纹理背景。
**图标风格**
- 1.5px 线宽 + 小实心点装饰。
**组件建议**
- 按钮:渐变或实心 + Hover 发光。
- 卡片:更明显阴影 + 彩色边角。
- 输入:轻微色彩底。
---
## 共享视觉资产
**封面比例**
- 列表卡片21:9
- 详情头图16:9
**插画 vs 照片**
- 默认使用插画,避免真实儿童照片(隐私与合规)。
**空态插画**
- 统一 1 张主插画,做颜色变体复用。
---
## 推荐
建议 Web MVP 使用方案 ASoft Aurora兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。

View File

@@ -0,0 +1,230 @@
# DreamWeaver 高保真页面布局与组件规格Web
## 范围
本文将每个页面映射为高保真布局规范:结构、核心组件与状态,便于在 Figma 中快速搭建。
---
## 全局布局
- 画布1440 x 900
- 内容容器1200px 居中
- 栅格12 列24px 间距
- 基础间距8pt
---
## 全局组件
**顶部导航**
-Logo + 产品名
- 中:主导航
- 右:搜索、孩子切换器、头像菜单
**主 CTA**
- 主色实心按钮
**卡片**
- 21:9 封面
- 标题、标签、元信息、操作
**表单控件**
- 文本输入、选择器、日期、滑块、标签
**状态**
- 空态、加载、错误、离线
---
## 1) 登录 / 授权
**结构**
- 渐变背景
- 居中卡片420px 宽)
**组件**
- Logo 组合
- 标题 + 副标题
- OAuth 按钮GitHub、Google
- 隐私说明
**状态**
- Loading按钮 spinner
- Error行内错误
---
## 2) 首页:生成故事
**结构**
- 双栏布局(左表单、右预览)
- 顶部步骤条
**左侧表单**
- 孩子选择器(下拉 + 头像)
- 宇宙选择器(延续 / 新建)
- 关键词标签输入
- 成长主题选择
- 长度选择(分段按钮)
- 生成按钮
**右侧预览**
- 封面占位
- 标题占位
- 摘要预览
- 进度指示(文本 -> 封面 -> 语音)
**状态**
- 空预览
- 生成中(进度)
- 封面失败(重试)
---
## 3) 我的故事(列表)
**结构**
- 工具条 + 网格列表
**工具条**
- 搜索
- 筛选(孩子、标签)
- 排序(最新、最早)
- 视图切换(网格/列表)
**网格卡片**
- 桌面端 3 列
- Hover 操作:阅读、重生成封面、删除
**状态**
- 空列表 + CTA
- 骨架屏
---
## 4) 故事详情
**头图**
- 16:9 封面
- 标题 + 元信息(孩子、宇宙、标签)
- 操作按钮:重生成封面、生成语音、分享
**正文**
- 正文阅读区
- 成就面板
**音频**
- 底部吸附迷你播放器
**状态**
- 封面失败
- 语音未生成
- 语音加载中
---
## 5) 孩子档案
**列表视图**
- 头像卡片网格
- CTA添加档案
**详情视图**
- 头像头部 + 编辑按钮
- Tabs基础信息 / 兴趣与成长 / 故事宇宙 / 阅读记录
**编辑弹窗**
- 姓名、生日、性别
- 兴趣标签
- 成长主题
---
## 6) 故事宇宙
**列表视图**
- 宇宙卡片 + 摘要
- CTA新建宇宙
**详情视图**
- 摘要区
- 分区:主角、角色、世界观、成就
**创建/编辑**
- 结构化表单 + 示例提示
---
## 7) 推送设置
**结构**
- 卡片式设置
**组件**
- 主开关
- 时间选择 + 周期
- 触发开关
- 免打扰时段
- 文案预览
- 测试推送按钮
---
## 8) 账户设置
**组件**
- 个人信息
- OAuth 连接
- 数据导出/删除
- 语言(预留)
---
## 9) 管理后台Providers
**结构**
- 表格布局
**表格列**
- Provider 名称、类型、状态、延迟、最近检查
**操作**
- 编辑、禁用、重载
- JSON 配置编辑器(弹窗)
---
## 10) 404 / 错误 / 空态
**布局**
- 居中插画 + CTA
---
## 交互规范
- 按钮 Hover轻微放大 1.02
- 卡片 Hover抬升阴影
- Toast右上角自动消失
- 列表使用 Skeleton
---
## 响应式规则(移动端阶段)
- 顶部导航 -> 底部 Tab
- 双栏 -> 单栏
- 详情页操作 -> 底部吸附按钮
---
## Figma 搭建清单
- 新建 PageDesign System
- 新建 PageWeb Screens
- 建立颜色与字体样式
- 组件做 Variant
- 全部使用 Auto Layout
- 1440/1200/1024/768 建立栅格
- 状态页复制并标注

View File

@@ -0,0 +1,299 @@
# DreamWeaver Web 高保真原型规范 (v1)
## 范围与目标
- 目标:为 DreamWeaver 提供专业、Web 优先的高保真 UI/UX既温暖有想象力又让家长感到可信与高品质。
- 受众3-8 岁儿童的家长。
- 阶段重点Web 端(桌面/平板),同时制定响应式规则,方便后续移动端迁移。
- 假设:界面语言为简体中文;管理端或系统字段可能含英文。
---
## 设计方向
- 氛围:温柔、治愈、有想象力,但保持简洁与高级感(避免过度幼儿化)。
- 视觉风格:柔和渐变、圆润形状、插画风封面、轻量阴影、舒适中性色。
- UX 原则低阻力、流程清晰、反馈及时、错误可恢复、AI 失败时有明确兜底。
---
## 设计系统Web
### 栅格与布局
- 基准8pt 间距系统。
- 容器1200px 最大宽度,左右 24px 边距12 列栅格。
- 断点:
- 1440+(宽屏)
- 1200标准桌面
- 1024横屏平板
- 768竖屏平板
### 色板
- 主色 600: #6C5CE7
- 主色 500: #7C69FF
- 主色 100: #EAE7FF
- 强调粉: #FF8FB1
- 强调蓝: #65C3FF
- 成功: #34C759
- 警告: #F6A609
- 错误: #FF5A5F
- 中性 900: #1F2430
- 中性 700: #4B5563
- 中性 500: #9AA3B2
- 中性 200: #E5E7EB
- 中性 100: #F5F7FB
- 白色: #FFFFFF
### 字体
- 主体字体PingFang SC, Noto Sans SC, Inter, system-ui
- H132/40Semibold
- H224/32Semibold
- H320/28Semibold
- Body L16/24Regular
- Body M14/22Regular
- Caption12/18Regular
### 圆角与阴影
- 圆角12卡片、10输入框、8按钮、24胶囊标签
- 阴影 S0 4 16 rgba(31,36,48,0.08)
- 阴影 M0 10 30 rgba(31,36,48,0.12)
### 核心组件
- 顶部导航Logo、主 CTA、搜索、孩子切换器、头像菜单
- 侧边导航(可用于设置/管理):图标 + 文案
- 按钮Primary / Secondary / Ghost / Destructive
- 输入:文本、文本域、数字、日期、选择、滑块
- 标签:兴趣/成长主题(多选)
- 卡片故事、孩子、宇宙、Provider
- 弹窗:创建/编辑表单
- Toast成功/错误/提示
- Skeleton列表与故事内容
- 音频播放器:播放/暂停、进度、倍速、下载
- 空态:插画 + CTA
- 错误态:行内错误 + 重试
---
## 信息架构Web
顶级导航:
- 生成故事Home
- 我的故事
- 孩子档案
- 故事宇宙
- 推送设置
- 账户设置
- 管理后台(仅开启时显示)
---
## 页面规格(高保真)
### 1) 登录/授权
**布局**
- 渐变背景 + 居中卡片
- Logo、Slogan、OAuth 按钮
**元素**
- 标题:“欢迎来到 DreamWeaver”
- 副标题:“为孩子生成独一无二的故事”
- 按钮:“使用 GitHub 登录”、“使用 Google 登录”
- 隐私说明:“我们仅使用公开信息创建账户”
**状态**
- Loading按钮 spinner
- Error行内错误 + 重试
---
### 2) 生成故事Home
**布局**
- 左表单 / 右预览双栏
- 顶部步骤条
**主表单**
- 孩子选择器:头像 + 姓名 + 年龄,含“新建档案”入口
- 宇宙选择器:默认“延续上一次”,可切“新建宇宙”
- 关键词输入(标签 + 手输)
- 成长主题选择(可选)
- 故事长度(短/中/长)
- 主要 CTA“生成故事”
**预览面板**
- 标题占位
- 封面占位
- 摘要预览
- 错误态:封面失败提示 + 重试
**交互**
- Stepper档案 → 宇宙 → 关键词 → 生成
- 生成过程:阶段进度(文本/封面/语音)
---
### 3) 我的故事(列表)
**布局**
- 顶部工具条 + 网格列表
**卡片**
- 21:9 封面、标题、标签、所属孩子、更新时间
- Hover 操作:继续阅读、重新生成封面、删除
**筛选**
- 孩子
- 标签
- 时间范围
**空态**
- 插画 + “开始生成第一个故事”
---
### 4) 故事详情
**头图**
- 大封面
- 标题 + 元信息(孩子、宇宙、标签、日期)
- 主操作:生成封面 / 生成语音 / 分享
**内容区**
- 正文排版(舒适行高)
- 成就模块(卡片式)
**音频**
- 滚动时底部吸附播放器
- 倍速切换0.8/1.0/1.2
**状态**
- 封面失败:占位 + 重试
- 语音未生成:显示 CTA
---
### 5) 孩子档案
**列表**
- 头像卡片 + 基础信息
- CTA“添加档案”
**详情**
- 档案头部 + 编辑
- Tabs基础信息 / 兴趣与成长 / 故事宇宙 / 阅读记录
**编辑弹窗**
- 姓名、生日、性别
- 兴趣标签(多选)
- 成长主题(单选或多选)
---
### 6) 故事宇宙
**列表**
- 宇宙卡片:主角、常驻角色、成就数量
- CTA“新建宇宙”
**详情**
- 宇宙摘要
- 可编辑区:主角 / 角色 / 世界观
- 成就时间轴
**创建/编辑**
- 结构化表单 + 示例提示
---
### 7) 推送设置
**布局**
- 卡片式设置区
**设置项**
- 主开关:开启主动推送
- 时间选择 + 周期
- 触发类型(复选)
- 免打扰时段
**预览**
- 推送文案预览
- 测试推送按钮
---
### 8) 账户设置
- 个人信息
- OAuth 绑定
- 数据隐私(导出/删除)
- 语言切换(预留)
---
### 9) 管理后台Provider
**表格**
- Provider 列表:状态、延迟、最近检查
- 操作:编辑、禁用、重载
**详情**
- JSON 配置编辑器(等宽字体)
- 健康检查按钮
---
### 10) 404 / 错误 / 空态
- 友好插画 + 返回 CTA
---
## 交互与动效
- 按钮Hover 轻微放大1.02
- 卡片Hover 提升阴影
- Loading列表 Skeleton、生成进度
- Toast右上角3s 自动消失
---
## 可访问性
- 文本对比度 >= 4.5:1
- 输入框/按钮焦点态清晰
- 最小触控区域 44px
---
## 响应式策略(移动端阶段)
- 顶部导航改为底部 Tab
- 双栏变单栏
- 详情页操作改为底部吸附操作条
- 卡片 1 列展示,触控面积更大
---
## Figma 实现说明
- 全部使用 Auto Layout
- 统一命名Page/Section/Component/State
- 按钮、输入、卡片做 Variant
- 颜色/字体/间距作为样式管理
---
## 交付物
- 设计系统库(色板、文字、组件)
- 全流程高保真页面
- 原型链接Figma 中生成)

Binary file not shown.

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>账户设置</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="grid grid-2 section">
<div class="card">
<h3>个人信息</h3>
<div class="row section">
<input class="input" placeholder="昵称" value="Dream Parent" />
<input class="input" placeholder="邮箱" value="parent@example.com" />
</div>
<button class="btn btn--primary">保存</button>
</div>
<div class="card">
<h3>账号安全</h3>
<div class="callout">已绑定 GitHub、Google</div>
<button class="btn btn--secondary" style="margin-top: 12px;">管理绑定</button>
</div>
<div class="card">
<h3>数据隐私</h3>
<button class="btn btn--secondary">导出数据</button>
<button class="btn btn--danger" style="margin-top: 12px;">删除账户</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Providers</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>Providers 管理</h2>
<button class="btn btn--primary">新增 Provider</button>
</div>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>状态</th>
<th>延迟</th>
<th>最近检查</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>text_primary</td>
<td>Text</td>
<td><span class="badge">健康</span></td>
<td>420ms</td>
<td>2 分钟前</td>
<td><a href="#">编辑</a> · <a href="#">禁用</a> · <a href="#">重载</a></td>
</tr>
<tr>
<td>image_primary</td>
<td>Image</td>
<td><span class="badge">健康</span></td>
<td>860ms</td>
<td>5 分钟前</td>
<td><a href="#">编辑</a> · <a href="#">禁用</a> · <a href="#">重载</a></td>
</tr>
</tbody>
</table>
<div class="footer-note">点击编辑后弹出 JSON 配置编辑器。</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>孩子档案详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="card section" style="display:flex; align-items:center; justify-content: space-between;">
<div style="display:flex; gap:12px; align-items:center;">
<div class="avatar"></div>
<div>
<div class="card-title">小明 · 5岁</div>
<div class="card-meta">男 · 生日 2020/05/12</div>
</div>
</div>
<button class="btn btn--secondary">编辑档案</button>
</div>
<div class="tabs section">
<div class="tab active">基础信息</div>
<div class="tab">兴趣与成长</div>
<div class="tab">故事宇宙</div>
<div class="tab">阅读记录</div>
</div>
<div class="grid grid-2 section">
<div class="card">
<h3>兴趣标签</h3>
<div class="chips">
<span class="chip selected">太空</span>
<span class="chip selected">机器人</span>
<span class="chip">冒险</span>
</div>
</div>
<div class="card">
<h3>成长主题</h3>
<div class="chips">
<span class="chip selected">勇气</span>
<span class="chip">分享</span>
</div>
</div>
</div>
<div class="section">
<h3>故事宇宙</h3>
<div class="grid grid-2">
<div class="card">
<div class="card-title">星际冒险</div>
<div class="card-meta">主角:小明船长 · 成就 3 个</div>
</div>
<div class="card">
<div class="card-title">梦幻森林</div>
<div class="card-meta">主角:森林守护者 · 成就 1 个</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>孩子档案</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>我的宝贝</h2>
<button class="btn btn--primary">添加档案</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="avatar"></div>
<div class="card-title">小明 · 5岁</div>
<div class="chips"><span class="chip">太空</span><span class="chip">机器人</span></div>
</div>
<div class="card">
<div class="avatar"></div>
<div class="card-title">小红 · 3岁</div>
<div class="chips"><span class="chip">公主</span><span class="chip">动物</span></div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:添加一个孩子档案</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>生成故事</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="stepper section">
<span class="step active">档案</span>
<span class="step">宇宙</span>
<span class="step">关键词</span>
<span class="step">生成</span>
</div>
<div class="split section">
<div class="card">
<h3>为谁创作故事</h3>
<div class="row section">
<div>
<label>孩子档案</label>
<select>
<option>小明 · 5岁</option>
<option>小红 · 3岁</option>
</select>
</div>
<div>
<label>故事宇宙</label>
<select>
<option>延续上一次(星际冒险)</option>
<option>新建宇宙</option>
</select>
</div>
</div>
<div class="section">
<label>关键词</label>
<div class="chips section">
<span class="chip selected">太空</span>
<span class="chip selected">勇气</span>
<span class="chip">机器人</span>
<span class="chip">探索</span>
</div>
<input class="input" placeholder="输入更多关键词" />
</div>
<div class="row section">
<div>
<label>成长主题</label>
<select>
<option>勇气</option>
<option>分享</option>
<option>独立</option>
</select>
</div>
<div>
<label>故事长度</label>
<div class="chips">
<span class="chip selected"></span>
<span class="chip"></span>
<span class="chip"></span>
</div>
</div>
</div>
<div class="section">
<button class="btn btn--primary" style="width: 100%;">生成故事</button>
</div>
</div>
<div class="card">
<h3>生成预览</h3>
<div class="card-cover"></div>
<div class="card-title">故事标题占位</div>
<p class="card-meta">故事摘要将显示在这里,支持 2-3 行预览。</p>
<div class="section">
<div class="callout">生成中:文本 → 封面 → 语音</div>
</div>
<div class="section">
<div class="callout" style="border-color: var(--error); color: var(--error);">封面生成失败,稍后重试</div>
<button class="btn btn--secondary" style="margin-top: 8px;">重新生成封面</button>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DreamWeaver 原型入口</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 48px 0;">
<div class="hero">
<h1>DreamWeaver HTML 原型入口</h1>
<p>请选择页面进行导入或预览HTML to Figma</p>
<div class="grid grid-3 section">
<div class="card"><a href="login.html">登录 / 授权</a></div>
<div class="card"><a href="home.html">生成故事Home</a></div>
<div class="card"><a href="my-stories.html">我的故事(列表)</a></div>
<div class="card"><a href="story-detail.html">故事详情</a></div>
<div class="card"><a href="child-profiles.html">孩子档案(列表)</a></div>
<div class="card"><a href="child-profile-detail.html">孩子档案(详情)</a></div>
<div class="card"><a href="universes.html">故事宇宙(列表)</a></div>
<div class="card"><a href="universe-detail.html">故事宇宙(详情)</a></div>
<div class="card"><a href="push-settings.html">推送设置</a></div>
<div class="card"><a href="account-settings.html">账户设置</a></div>
<div class="card"><a href="admin-providers.html">管理后台Providers</a></div>
<div class="card"><a href="not-found.html">404 / 错误</a></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录 / 授权</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 80px 0;">
<div class="hero" style="max-width: 420px; margin: 0 auto; text-align: center;">
<div class="nav__logo" style="justify-content: center;">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<h2 style="margin-top: 16px;">欢迎来到 DreamWeaver</h2>
<p>为孩子生成独一无二的故事</p>
<div class="section" style="display: grid; gap: 12px;">
<button class="btn btn--primary">使用 GitHub 登录</button>
<button class="btn btn--secondary">使用 Google 登录</button>
</div>
<div class="footer-note">我们仅使用公开信息创建账户</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我的故事</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<input class="input" style="width: 260px;" placeholder="搜索标题或关键词" />
<select style="width: 160px;">
<option>孩子:全部</option>
<option>小明</option>
<option>小红</option>
</select>
<select style="width: 160px;">
<option>排序:最新</option>
<option>最早</option>
</select>
<button class="btn btn--ghost">网格</button>
<button class="btn btn--ghost">列表</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="card-cover"></div>
<div class="card-title">星际冒险 · 第三章</div>
<div class="chips">
<span class="chip">太空</span><span class="chip">勇气</span>
</div>
<div class="card-meta">小明 · 更新于 2 天前</div>
<div class="section hero-actions">
<button class="btn btn--primary">继续阅读</button>
<button class="btn btn--secondary">重生成封面</button>
</div>
</div>
<div class="card">
<div class="card-cover"></div>
<div class="card-title">梦幻森林 · 朋友篇</div>
<div class="chips">
<span class="chip">友谊</span><span class="chip">动物</span>
</div>
<div class="card-meta">小红 · 更新于 5 天前</div>
<div class="section hero-actions">
<button class="btn btn--primary">继续阅读</button>
<button class="btn btn--danger">删除</button>
</div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:开始生成第一个故事</div>
<button class="btn btn--primary" style="margin-top: 12px;">生成故事</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 80px 0; text-align:center;">
<div class="hero">
<h1>404</h1>
<p>页面走丢了,回到生成故事开始吧。</p>
<button class="btn btn--primary">返回首页</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>推送设置</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="grid grid-2 section">
<div class="card">
<h3>主动推送</h3>
<div class="row section">
<div>
<label>主开关</label>
<select>
<option>开启</option>
<option>关闭</option>
</select>
</div>
<div>
<label>推送时间</label>
<input class="input" placeholder="20:00" />
</div>
</div>
<div class="section">
<label>触发类型</label>
<div class="chips">
<span class="chip selected">时间触发</span>
<span class="chip selected">事件触发</span>
<span class="chip">行为触发</span>
<span class="chip">成长触发</span>
</div>
</div>
<div class="section">
<label>免打扰</label>
<div class="row">
<input class="input" placeholder="21:00" />
<input class="input" placeholder="09:00" />
</div>
</div>
</div>
<div class="card">
<h3>推送预览</h3>
<div class="callout">“今晚给小明讲一个关于太空的故事,好吗?”</div>
<button class="btn btn--secondary" style="margin-top: 12px;">发送测试推送</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>故事详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="section">
<div class="cover-hero"></div>
<h2 style="margin-top: 16px;">星际冒险 · 勇气的种子</h2>
<div class="card-meta">小明 · 星际冒险宇宙 · 2025/01/12</div>
<div class="hero-actions section">
<button class="btn btn--secondary">重新生成封面</button>
<button class="btn btn--primary">生成语音</button>
<button class="btn btn--ghost">分享</button>
</div>
</div>
<div class="split section">
<div class="card">
<h3>故事正文</h3>
<p>夜空像一条温柔的河流,小明驾驶着飞船穿过星光……</p>
<p>他握紧操纵杆,鼓起勇气,向未知的星球靠近。</p>
<p>最终,小明发现了新的朋友,也学会了如何面对黑暗。</p>
</div>
<div class="card">
<h3>成就</h3>
<div class="chips section">
<span class="chip selected">勇气</span>
<span class="chip selected">友谊</span>
</div>
<div class="callout section">“克服了黑暗的恐惧”</div>
<div class="callout">“帮助了迷路的小伙伴”</div>
</div>
</div>
<div class="section audio-player">
<button class="btn btn--ghost">播放</button>
<div class="audio-bar"><div class="audio-progress"></div></div>
<button class="btn btn--ghost">1.0x</button>
</div>
<div class="footer-note">语音未生成时,显示“生成语音”按钮作为主操作。</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,217 @@
:root {
--primary-600: #6C5CE7;
--primary-500: #7C69FF;
--primary-100: #EAE7FF;
--accent-pink: #FF8FB1;
--accent-sky: #65C3FF;
--success: #34C759;
--warning: #F6A609;
--error: #FF5A5F;
--neutral-900: #1F2430;
--neutral-700: #4B5563;
--neutral-500: #9AA3B2;
--neutral-200: #E5E7EB;
--neutral-100: #F5F7FB;
--hero-gradient: linear-gradient(135deg, #EAE7FF 0%, #FDF6FF 40%, #EAF6FF 100%);
}
* { box-sizing: border-box; }
:root {
--container-width: 1200px;
--gutter: 24px;
--radius-card: 12px;
--radius-input: 10px;
--radius-button: 8px;
--radius-pill: 24px;
--shadow-s: 0 4px 16px rgba(31,36,48,0.08);
--shadow-m: 0 10px 30px rgba(31,36,48,0.12);
}
body {
margin: 0;
font-family: "PingFang SC", "Noto Sans SC", Inter, system-ui, -apple-system, sans-serif;
color: var(--neutral-900);
background: var(--neutral-100);
}
a { color: var(--primary-600); text-decoration: none; }
.page { min-height: 100vh; }
.container {
width: min(var(--container-width), 100% - 48px);
margin: 0 auto;
}
.nav {
background: #fff;
border-bottom: 1px solid var(--neutral-200);
position: sticky;
top: 0;
z-index: 10;
}
.nav__inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
gap: 16px;
}
.nav__left, .nav__center, .nav__right { display: flex; align-items: center; gap: 16px; }
.nav__logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: var(--neutral-900);
}
.nav__logo-badge {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary-500), var(--accent-sky));
}
.nav__item { color: var(--neutral-700); font-weight: 500; }
.nav__item.active { color: var(--primary-600); }
.hero {
background: var(--hero-gradient);
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-s);
}
.section { margin: 28px 0; }
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.card {
background: #fff;
border-radius: var(--radius-card);
padding: 16px;
box-shadow: var(--shadow-s);
}
.card--flat { box-shadow: none; border: 1px solid var(--neutral-200); }
.card-cover {
width: 100%;
aspect-ratio: 21 / 9;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary-100), #fff 60%, var(--accent-sky));
margin-bottom: 12px;
}
.card-title { font-weight: 600; margin: 6px 0; }
.card-meta { color: var(--neutral-500); font-size: 12px; }
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--primary-100);
color: var(--primary-600);
font-size: 12px;
}
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
padding: 6px 12px;
border-radius: var(--radius-pill);
border: 1px solid var(--neutral-200);
background: #fff;
font-size: 12px;
}
.chip.selected { background: var(--primary-100); border-color: var(--primary-500); color: var(--primary-600); }
.btn {
height: 40px;
padding: 0 16px;
border-radius: var(--radius-button);
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
}
.btn--primary { background: var(--primary-600); color: #fff; }
.btn--secondary { background: #fff; border-color: var(--primary-600); color: var(--primary-600); }
.btn--ghost { background: transparent; color: var(--neutral-700); }
.btn--danger { background: #fff; border-color: var(--error); color: var(--error); }
.input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: var(--radius-input);
border: 1px solid var(--neutral-200);
background: #fff;
font-size: 14px;
}
.row { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; }
.stepper { display: flex; gap: 10px; align-items: center; }
.step {
padding: 6px 12px;
border-radius: var(--radius-pill);
background: var(--neutral-100);
color: var(--neutral-700);
font-size: 12px;
}
.step.active { background: var(--primary-100); color: var(--primary-600); }
.toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid var(--neutral-200); padding: 12px 8px; text-align: left; font-size: 14px; }
.avatar {
width: 40px; height: 40px; border-radius: 50%;
background: var(--primary-100);
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; color: var(--primary-600);
}
.cover-hero {
aspect-ratio: 16 / 9;
border-radius: 14px;
background: linear-gradient(135deg, var(--primary-100), #fff 55%, var(--accent-pink));
}
.audio-player {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
border-radius: 12px; border: 1px solid var(--neutral-200); background: #fff;
}
.audio-bar { height: 6px; background: var(--neutral-200); border-radius: 999px; flex: 1; }
.audio-progress { width: 35%; height: 100%; background: var(--primary-600); border-radius: 999px; }
.tabs { display: flex; gap: 8px; border-bottom: 1px solid var(--neutral-200); }
.tab { padding: 10px 12px; color: var(--neutral-700); }
.tab.active { color: var(--primary-600); border-bottom: 2px solid var(--primary-600); }
.callout {
background: #fff;
border: 1px dashed var(--neutral-200);
border-radius: 12px;
padding: 12px;
color: var(--neutral-700);
font-size: 12px;
}
.footer-note { color: var(--neutral-500); font-size: 12px; margin-top: 12px; }
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.split {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
@media (max-width: 1024px) {
.grid-3 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.split { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.row { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>宇宙详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="card section">
<h2>星际冒险</h2>
<div class="card-meta">主角:小明船长 · 更新于 2025/01/12</div>
</div>
<div class="grid grid-2 section">
<div class="card">
<h3>主角设定</h3>
<div class="callout">小明是来自地球的探险家,勇敢且好奇。</div>
</div>
<div class="card">
<h3>常驻角色</h3>
<div class="callout">机器人小七、外星猫咪星星</div>
</div>
<div class="card">
<h3>世界观</h3>
<div class="callout">星际学院、彩虹星云、飞船港湾</div>
</div>
<div class="card">
<h3>成就</h3>
<div class="callout">克服恐惧 · 结交朋友 · 学会独立</div>
</div>
</div>
<div class="section">
<button class="btn btn--secondary">编辑宇宙</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>故事宇宙</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>故事宇宙</h2>
<button class="btn btn--primary">新建宇宙</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="card-title">星际冒险</div>
<div class="card-meta">主角:小明船长</div>
<div class="chips section">
<span class="chip">伙伴:机器人小七</span>
<span class="chip">成就3</span>
</div>
</div>
<div class="card">
<div class="card-title">梦幻森林</div>
<div class="card-meta">主角:森林守护者</div>
<div class="chips section">
<span class="chip">伙伴:魔法猫咪</span>
<span class="chip">成就1</span>
</div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:创建第一个宇宙</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>账户设置</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="grid grid-2 section">
<div class="card">
<h3>个人信息</h3>
<div class="row section">
<input class="input" placeholder="昵称" value="Dream Parent" />
<input class="input" placeholder="邮箱" value="parent@example.com" />
</div>
<button class="btn btn--primary">保存</button>
</div>
<div class="card">
<h3>账号安全</h3>
<div class="callout">已绑定 GitHub、Google</div>
<button class="btn btn--secondary" style="margin-top: 12px;">管理绑定</button>
</div>
<div class="card">
<h3>数据隐私</h3>
<button class="btn btn--secondary">导出数据</button>
<button class="btn btn--danger" style="margin-top: 12px;">删除账户</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Providers</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>Providers 管理</h2>
<button class="btn btn--primary">新增 Provider</button>
</div>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>状态</th>
<th>延迟</th>
<th>最近检查</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>text_primary</td>
<td>Text</td>
<td><span class="badge">健康</span></td>
<td>420ms</td>
<td>2 分钟前</td>
<td><a href="#">编辑</a> · <a href="#">禁用</a> · <a href="#">重载</a></td>
</tr>
<tr>
<td>image_primary</td>
<td>Image</td>
<td><span class="badge">健康</span></td>
<td>860ms</td>
<td>5 分钟前</td>
<td><a href="#">编辑</a> · <a href="#">禁用</a> · <a href="#">重载</a></td>
</tr>
</tbody>
</table>
<div class="footer-note">点击编辑后弹出 JSON 配置编辑器。</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>孩子档案详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="card section" style="display:flex; align-items:center; justify-content: space-between;">
<div style="display:flex; gap:12px; align-items:center;">
<div class="avatar"></div>
<div>
<div class="card-title">小明 · 5岁</div>
<div class="card-meta">男 · 生日 2020/05/12</div>
</div>
</div>
<button class="btn btn--secondary">编辑档案</button>
</div>
<div class="tabs section">
<div class="tab active">基础信息</div>
<div class="tab">兴趣与成长</div>
<div class="tab">故事宇宙</div>
<div class="tab">阅读记录</div>
</div>
<div class="grid grid-2 section">
<div class="card">
<h3>兴趣标签</h3>
<div class="chips">
<span class="chip selected">太空</span>
<span class="chip selected">机器人</span>
<span class="chip">冒险</span>
</div>
</div>
<div class="card">
<h3>成长主题</h3>
<div class="chips">
<span class="chip selected">勇气</span>
<span class="chip">分享</span>
</div>
</div>
</div>
<div class="section">
<h3>故事宇宙</h3>
<div class="grid grid-2">
<div class="card">
<div class="card-title">星际冒险</div>
<div class="card-meta">主角:小明船长 · 成就 3 个</div>
</div>
<div class="card">
<div class="card-title">梦幻森林</div>
<div class="card-meta">主角:森林守护者 · 成就 1 个</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>孩子档案</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>我的宝贝</h2>
<button class="btn btn--primary">添加档案</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="avatar"></div>
<div class="card-title">小明 · 5岁</div>
<div class="chips"><span class="chip">太空</span><span class="chip">机器人</span></div>
</div>
<div class="card">
<div class="avatar"></div>
<div class="card-title">小红 · 3岁</div>
<div class="chips"><span class="chip">公主</span><span class="chip">动物</span></div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:添加一个孩子档案</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>生成故事</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="stepper section">
<span class="step active">档案</span>
<span class="step">宇宙</span>
<span class="step">关键词</span>
<span class="step">生成</span>
</div>
<div class="split section">
<div class="card">
<h3>为谁创作故事</h3>
<div class="row section">
<div>
<label>孩子档案</label>
<select>
<option>小明 · 5岁</option>
<option>小红 · 3岁</option>
</select>
</div>
<div>
<label>故事宇宙</label>
<select>
<option>延续上一次(星际冒险)</option>
<option>新建宇宙</option>
</select>
</div>
</div>
<div class="section">
<label>关键词</label>
<div class="chips section">
<span class="chip selected">太空</span>
<span class="chip selected">勇气</span>
<span class="chip">机器人</span>
<span class="chip">探索</span>
</div>
<input class="input" placeholder="输入更多关键词" />
</div>
<div class="row section">
<div>
<label>成长主题</label>
<select>
<option>勇气</option>
<option>分享</option>
<option>独立</option>
</select>
</div>
<div>
<label>故事长度</label>
<div class="chips">
<span class="chip selected"></span>
<span class="chip"></span>
<span class="chip"></span>
</div>
</div>
</div>
<div class="section">
<button class="btn btn--primary" style="width: 100%;">生成故事</button>
</div>
</div>
<div class="card">
<h3>生成预览</h3>
<div class="card-cover"></div>
<div class="card-title">故事标题占位</div>
<p class="card-meta">故事摘要将显示在这里,支持 2-3 行预览。</p>
<div class="section">
<div class="callout">生成中:文本 → 封面 → 语音</div>
</div>
<div class="section">
<div class="callout" style="border-color: var(--error); color: var(--error);">封面生成失败,稍后重试</div>
<button class="btn btn--secondary" style="margin-top: 8px;">重新生成封面</button>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DreamWeaver 原型入口</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 48px 0;">
<div class="hero">
<h1>DreamWeaver HTML 原型入口</h1>
<p>请选择页面进行导入或预览HTML to Figma</p>
<div class="grid grid-3 section">
<div class="card"><a href="login.html">登录 / 授权</a></div>
<div class="card"><a href="home.html">生成故事Home</a></div>
<div class="card"><a href="my-stories.html">我的故事(列表)</a></div>
<div class="card"><a href="story-detail.html">故事详情</a></div>
<div class="card"><a href="child-profiles.html">孩子档案(列表)</a></div>
<div class="card"><a href="child-profile-detail.html">孩子档案(详情)</a></div>
<div class="card"><a href="universes.html">故事宇宙(列表)</a></div>
<div class="card"><a href="universe-detail.html">故事宇宙(详情)</a></div>
<div class="card"><a href="push-settings.html">推送设置</a></div>
<div class="card"><a href="account-settings.html">账户设置</a></div>
<div class="card"><a href="admin-providers.html">管理后台Providers</a></div>
<div class="card"><a href="not-found.html">404 / 错误</a></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录 / 授权</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 80px 0;">
<div class="hero" style="max-width: 420px; margin: 0 auto; text-align: center;">
<div class="nav__logo" style="justify-content: center;">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<h2 style="margin-top: 16px;">欢迎来到 DreamWeaver</h2>
<p>为孩子生成独一无二的故事</p>
<div class="section" style="display: grid; gap: 12px;">
<button class="btn btn--primary">使用 GitHub 登录</button>
<button class="btn btn--secondary">使用 Google 登录</button>
</div>
<div class="footer-note">我们仅使用公开信息创建账户</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我的故事</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<input class="input" style="width: 260px;" placeholder="搜索标题或关键词" />
<select style="width: 160px;">
<option>孩子:全部</option>
<option>小明</option>
<option>小红</option>
</select>
<select style="width: 160px;">
<option>排序:最新</option>
<option>最早</option>
</select>
<button class="btn btn--ghost">网格</button>
<button class="btn btn--ghost">列表</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="card-cover"></div>
<div class="card-title">星际冒险 · 第三章</div>
<div class="chips">
<span class="chip">太空</span><span class="chip">勇气</span>
</div>
<div class="card-meta">小明 · 更新于 2 天前</div>
<div class="section hero-actions">
<button class="btn btn--primary">继续阅读</button>
<button class="btn btn--secondary">重生成封面</button>
</div>
</div>
<div class="card">
<div class="card-cover"></div>
<div class="card-title">梦幻森林 · 朋友篇</div>
<div class="chips">
<span class="chip">友谊</span><span class="chip">动物</span>
</div>
<div class="card-meta">小红 · 更新于 5 天前</div>
<div class="section hero-actions">
<button class="btn btn--primary">继续阅读</button>
<button class="btn btn--danger">删除</button>
</div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:开始生成第一个故事</div>
<button class="btn btn--primary" style="margin-top: 12px;">生成故事</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 80px 0; text-align:center;">
<div class="hero">
<h1>404</h1>
<p>页面走丢了,回到生成故事开始吧。</p>
<button class="btn btn--primary">返回首页</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>推送设置</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="grid grid-2 section">
<div class="card">
<h3>主动推送</h3>
<div class="row section">
<div>
<label>主开关</label>
<select>
<option>开启</option>
<option>关闭</option>
</select>
</div>
<div>
<label>推送时间</label>
<input class="input" placeholder="20:00" />
</div>
</div>
<div class="section">
<label>触发类型</label>
<div class="chips">
<span class="chip selected">时间触发</span>
<span class="chip selected">事件触发</span>
<span class="chip">行为触发</span>
<span class="chip">成长触发</span>
</div>
</div>
<div class="section">
<label>免打扰</label>
<div class="row">
<input class="input" placeholder="21:00" />
<input class="input" placeholder="09:00" />
</div>
</div>
</div>
<div class="card">
<h3>推送预览</h3>
<div class="callout">“今晚给小明讲一个关于太空的故事,好吗?”</div>
<button class="btn btn--secondary" style="margin-top: 12px;">发送测试推送</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>故事详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="section">
<div class="cover-hero"></div>
<h2 style="margin-top: 16px;">星际冒险 · 勇气的种子</h2>
<div class="card-meta">小明 · 星际冒险宇宙 · 2025/01/12</div>
<div class="hero-actions section">
<button class="btn btn--secondary">重新生成封面</button>
<button class="btn btn--primary">生成语音</button>
<button class="btn btn--ghost">分享</button>
</div>
</div>
<div class="split section">
<div class="card">
<h3>故事正文</h3>
<p>夜空像一条温柔的河流,小明驾驶着飞船穿过星光……</p>
<p>他握紧操纵杆,鼓起勇气,向未知的星球靠近。</p>
<p>最终,小明发现了新的朋友,也学会了如何面对黑暗。</p>
</div>
<div class="card">
<h3>成就</h3>
<div class="chips section">
<span class="chip selected">勇气</span>
<span class="chip selected">友谊</span>
</div>
<div class="callout section">“克服了黑暗的恐惧”</div>
<div class="callout">“帮助了迷路的小伙伴”</div>
</div>
</div>
<div class="section audio-player">
<button class="btn btn--ghost">播放</button>
<div class="audio-bar"><div class="audio-progress"></div></div>
<button class="btn btn--ghost">1.0x</button>
</div>
<div class="footer-note">语音未生成时,显示“生成语音”按钮作为主操作。</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,217 @@
:root {
--primary-600: #3B82F6;
--primary-500: #60A5FA;
--primary-100: #DBEAFE;
--accent-pink: #F5C542;
--accent-sky: #6EE7B7;
--success: #34C759;
--warning: #F6A609;
--error: #FF5A5F;
--neutral-900: #111827;
--neutral-700: #374151;
--neutral-500: #9CA3AF;
--neutral-200: #E5E7EB;
--neutral-100: #F9FAFB;
--hero-gradient: linear-gradient(180deg, #F9FAFB 0%, #EEF2FF 100%);
}
* { box-sizing: border-box; }
:root {
--container-width: 1200px;
--gutter: 24px;
--radius-card: 12px;
--radius-input: 10px;
--radius-button: 8px;
--radius-pill: 24px;
--shadow-s: 0 4px 16px rgba(31,36,48,0.08);
--shadow-m: 0 10px 30px rgba(31,36,48,0.12);
}
body {
margin: 0;
font-family: "PingFang SC", "Noto Sans SC", Inter, system-ui, -apple-system, sans-serif;
color: var(--neutral-900);
background: var(--neutral-100);
}
a { color: var(--primary-600); text-decoration: none; }
.page { min-height: 100vh; }
.container {
width: min(var(--container-width), 100% - 48px);
margin: 0 auto;
}
.nav {
background: #fff;
border-bottom: 1px solid var(--neutral-200);
position: sticky;
top: 0;
z-index: 10;
}
.nav__inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
gap: 16px;
}
.nav__left, .nav__center, .nav__right { display: flex; align-items: center; gap: 16px; }
.nav__logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: var(--neutral-900);
}
.nav__logo-badge {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary-500), var(--accent-sky));
}
.nav__item { color: var(--neutral-700); font-weight: 500; }
.nav__item.active { color: var(--primary-600); }
.hero {
background: var(--hero-gradient);
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-s);
}
.section { margin: 28px 0; }
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.card {
background: #fff;
border-radius: var(--radius-card);
padding: 16px;
box-shadow: var(--shadow-s);
}
.card--flat { box-shadow: none; border: 1px solid var(--neutral-200); }
.card-cover {
width: 100%;
aspect-ratio: 21 / 9;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary-100), #fff 60%, var(--accent-sky));
margin-bottom: 12px;
}
.card-title { font-weight: 600; margin: 6px 0; }
.card-meta { color: var(--neutral-500); font-size: 12px; }
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--primary-100);
color: var(--primary-600);
font-size: 12px;
}
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
padding: 6px 12px;
border-radius: var(--radius-pill);
border: 1px solid var(--neutral-200);
background: #fff;
font-size: 12px;
}
.chip.selected { background: var(--primary-100); border-color: var(--primary-500); color: var(--primary-600); }
.btn {
height: 40px;
padding: 0 16px;
border-radius: var(--radius-button);
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
}
.btn--primary { background: var(--primary-600); color: #fff; }
.btn--secondary { background: #fff; border-color: var(--primary-600); color: var(--primary-600); }
.btn--ghost { background: transparent; color: var(--neutral-700); }
.btn--danger { background: #fff; border-color: var(--error); color: var(--error); }
.input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: var(--radius-input);
border: 1px solid var(--neutral-200);
background: #fff;
font-size: 14px;
}
.row { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; }
.stepper { display: flex; gap: 10px; align-items: center; }
.step {
padding: 6px 12px;
border-radius: var(--radius-pill);
background: var(--neutral-100);
color: var(--neutral-700);
font-size: 12px;
}
.step.active { background: var(--primary-100); color: var(--primary-600); }
.toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid var(--neutral-200); padding: 12px 8px; text-align: left; font-size: 14px; }
.avatar {
width: 40px; height: 40px; border-radius: 50%;
background: var(--primary-100);
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; color: var(--primary-600);
}
.cover-hero {
aspect-ratio: 16 / 9;
border-radius: 14px;
background: linear-gradient(135deg, var(--primary-100), #fff 55%, var(--accent-pink));
}
.audio-player {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
border-radius: 12px; border: 1px solid var(--neutral-200); background: #fff;
}
.audio-bar { height: 6px; background: var(--neutral-200); border-radius: 999px; flex: 1; }
.audio-progress { width: 35%; height: 100%; background: var(--primary-600); border-radius: 999px; }
.tabs { display: flex; gap: 8px; border-bottom: 1px solid var(--neutral-200); }
.tab { padding: 10px 12px; color: var(--neutral-700); }
.tab.active { color: var(--primary-600); border-bottom: 2px solid var(--primary-600); }
.callout {
background: #fff;
border: 1px dashed var(--neutral-200);
border-radius: 12px;
padding: 12px;
color: var(--neutral-700);
font-size: 12px;
}
.footer-note { color: var(--neutral-500); font-size: 12px; margin-top: 12px; }
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.split {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
@media (max-width: 1024px) {
.grid-3 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.split { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.row { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>宇宙详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="card section">
<h2>星际冒险</h2>
<div class="card-meta">主角:小明船长 · 更新于 2025/01/12</div>
</div>
<div class="grid grid-2 section">
<div class="card">
<h3>主角设定</h3>
<div class="callout">小明是来自地球的探险家,勇敢且好奇。</div>
</div>
<div class="card">
<h3>常驻角色</h3>
<div class="callout">机器人小七、外星猫咪星星</div>
</div>
<div class="card">
<h3>世界观</h3>
<div class="callout">星际学院、彩虹星云、飞船港湾</div>
</div>
<div class="card">
<h3>成就</h3>
<div class="callout">克服恐惧 · 结交朋友 · 学会独立</div>
</div>
</div>
<div class="section">
<button class="btn btn--secondary">编辑宇宙</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>故事宇宙</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>故事宇宙</h2>
<button class="btn btn--primary">新建宇宙</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="card-title">星际冒险</div>
<div class="card-meta">主角:小明船长</div>
<div class="chips section">
<span class="chip">伙伴:机器人小七</span>
<span class="chip">成就3</span>
</div>
</div>
<div class="card">
<div class="card-title">梦幻森林</div>
<div class="card-meta">主角:森林守护者</div>
<div class="chips section">
<span class="chip">伙伴:魔法猫咪</span>
<span class="chip">成就1</span>
</div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:创建第一个宇宙</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>账户设置</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="grid grid-2 section">
<div class="card">
<h3>个人信息</h3>
<div class="row section">
<input class="input" placeholder="昵称" value="Dream Parent" />
<input class="input" placeholder="邮箱" value="parent@example.com" />
</div>
<button class="btn btn--primary">保存</button>
</div>
<div class="card">
<h3>账号安全</h3>
<div class="callout">已绑定 GitHub、Google</div>
<button class="btn btn--secondary" style="margin-top: 12px;">管理绑定</button>
</div>
<div class="card">
<h3>数据隐私</h3>
<button class="btn btn--secondary">导出数据</button>
<button class="btn btn--danger" style="margin-top: 12px;">删除账户</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Providers</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>Providers 管理</h2>
<button class="btn btn--primary">新增 Provider</button>
</div>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>状态</th>
<th>延迟</th>
<th>最近检查</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>text_primary</td>
<td>Text</td>
<td><span class="badge">健康</span></td>
<td>420ms</td>
<td>2 分钟前</td>
<td><a href="#">编辑</a> · <a href="#">禁用</a> · <a href="#">重载</a></td>
</tr>
<tr>
<td>image_primary</td>
<td>Image</td>
<td><span class="badge">健康</span></td>
<td>860ms</td>
<td>5 分钟前</td>
<td><a href="#">编辑</a> · <a href="#">禁用</a> · <a href="#">重载</a></td>
</tr>
</tbody>
</table>
<div class="footer-note">点击编辑后弹出 JSON 配置编辑器。</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>孩子档案详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="card section" style="display:flex; align-items:center; justify-content: space-between;">
<div style="display:flex; gap:12px; align-items:center;">
<div class="avatar"></div>
<div>
<div class="card-title">小明 · 5岁</div>
<div class="card-meta">男 · 生日 2020/05/12</div>
</div>
</div>
<button class="btn btn--secondary">编辑档案</button>
</div>
<div class="tabs section">
<div class="tab active">基础信息</div>
<div class="tab">兴趣与成长</div>
<div class="tab">故事宇宙</div>
<div class="tab">阅读记录</div>
</div>
<div class="grid grid-2 section">
<div class="card">
<h3>兴趣标签</h3>
<div class="chips">
<span class="chip selected">太空</span>
<span class="chip selected">机器人</span>
<span class="chip">冒险</span>
</div>
</div>
<div class="card">
<h3>成长主题</h3>
<div class="chips">
<span class="chip selected">勇气</span>
<span class="chip">分享</span>
</div>
</div>
</div>
<div class="section">
<h3>故事宇宙</h3>
<div class="grid grid-2">
<div class="card">
<div class="card-title">星际冒险</div>
<div class="card-meta">主角:小明船长 · 成就 3 个</div>
</div>
<div class="card">
<div class="card-title">梦幻森林</div>
<div class="card-meta">主角:森林守护者 · 成就 1 个</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>孩子档案</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>我的宝贝</h2>
<button class="btn btn--primary">添加档案</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="avatar"></div>
<div class="card-title">小明 · 5岁</div>
<div class="chips"><span class="chip">太空</span><span class="chip">机器人</span></div>
</div>
<div class="card">
<div class="avatar"></div>
<div class="card-title">小红 · 3岁</div>
<div class="chips"><span class="chip">公主</span><span class="chip">动物</span></div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:添加一个孩子档案</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>生成故事</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="stepper section">
<span class="step active">档案</span>
<span class="step">宇宙</span>
<span class="step">关键词</span>
<span class="step">生成</span>
</div>
<div class="split section">
<div class="card">
<h3>为谁创作故事</h3>
<div class="row section">
<div>
<label>孩子档案</label>
<select>
<option>小明 · 5岁</option>
<option>小红 · 3岁</option>
</select>
</div>
<div>
<label>故事宇宙</label>
<select>
<option>延续上一次(星际冒险)</option>
<option>新建宇宙</option>
</select>
</div>
</div>
<div class="section">
<label>关键词</label>
<div class="chips section">
<span class="chip selected">太空</span>
<span class="chip selected">勇气</span>
<span class="chip">机器人</span>
<span class="chip">探索</span>
</div>
<input class="input" placeholder="输入更多关键词" />
</div>
<div class="row section">
<div>
<label>成长主题</label>
<select>
<option>勇气</option>
<option>分享</option>
<option>独立</option>
</select>
</div>
<div>
<label>故事长度</label>
<div class="chips">
<span class="chip selected"></span>
<span class="chip"></span>
<span class="chip"></span>
</div>
</div>
</div>
<div class="section">
<button class="btn btn--primary" style="width: 100%;">生成故事</button>
</div>
</div>
<div class="card">
<h3>生成预览</h3>
<div class="card-cover"></div>
<div class="card-title">故事标题占位</div>
<p class="card-meta">故事摘要将显示在这里,支持 2-3 行预览。</p>
<div class="section">
<div class="callout">生成中:文本 → 封面 → 语音</div>
</div>
<div class="section">
<div class="callout" style="border-color: var(--error); color: var(--error);">封面生成失败,稍后重试</div>
<button class="btn btn--secondary" style="margin-top: 8px;">重新生成封面</button>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DreamWeaver 原型入口</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 48px 0;">
<div class="hero">
<h1>DreamWeaver HTML 原型入口</h1>
<p>请选择页面进行导入或预览HTML to Figma</p>
<div class="grid grid-3 section">
<div class="card"><a href="login.html">登录 / 授权</a></div>
<div class="card"><a href="home.html">生成故事Home</a></div>
<div class="card"><a href="my-stories.html">我的故事(列表)</a></div>
<div class="card"><a href="story-detail.html">故事详情</a></div>
<div class="card"><a href="child-profiles.html">孩子档案(列表)</a></div>
<div class="card"><a href="child-profile-detail.html">孩子档案(详情)</a></div>
<div class="card"><a href="universes.html">故事宇宙(列表)</a></div>
<div class="card"><a href="universe-detail.html">故事宇宙(详情)</a></div>
<div class="card"><a href="push-settings.html">推送设置</a></div>
<div class="card"><a href="account-settings.html">账户设置</a></div>
<div class="card"><a href="admin-providers.html">管理后台Providers</a></div>
<div class="card"><a href="not-found.html">404 / 错误</a></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录 / 授权</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 80px 0;">
<div class="hero" style="max-width: 420px; margin: 0 auto; text-align: center;">
<div class="nav__logo" style="justify-content: center;">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<h2 style="margin-top: 16px;">欢迎来到 DreamWeaver</h2>
<p>为孩子生成独一无二的故事</p>
<div class="section" style="display: grid; gap: 12px;">
<button class="btn btn--primary">使用 GitHub 登录</button>
<button class="btn btn--secondary">使用 Google 登录</button>
</div>
<div class="footer-note">我们仅使用公开信息创建账户</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我的故事</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<input class="input" style="width: 260px;" placeholder="搜索标题或关键词" />
<select style="width: 160px;">
<option>孩子:全部</option>
<option>小明</option>
<option>小红</option>
</select>
<select style="width: 160px;">
<option>排序:最新</option>
<option>最早</option>
</select>
<button class="btn btn--ghost">网格</button>
<button class="btn btn--ghost">列表</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="card-cover"></div>
<div class="card-title">星际冒险 · 第三章</div>
<div class="chips">
<span class="chip">太空</span><span class="chip">勇气</span>
</div>
<div class="card-meta">小明 · 更新于 2 天前</div>
<div class="section hero-actions">
<button class="btn btn--primary">继续阅读</button>
<button class="btn btn--secondary">重生成封面</button>
</div>
</div>
<div class="card">
<div class="card-cover"></div>
<div class="card-title">梦幻森林 · 朋友篇</div>
<div class="chips">
<span class="chip">友谊</span><span class="chip">动物</span>
</div>
<div class="card-meta">小红 · 更新于 5 天前</div>
<div class="section hero-actions">
<button class="btn btn--primary">继续阅读</button>
<button class="btn btn--danger">删除</button>
</div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:开始生成第一个故事</div>
<button class="btn btn--primary" style="margin-top: 12px;">生成故事</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="container" style="padding: 80px 0; text-align:center;">
<div class="hero">
<h1>404</h1>
<p>页面走丢了,回到生成故事开始吧。</p>
<button class="btn btn--primary">返回首页</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>推送设置</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="grid grid-2 section">
<div class="card">
<h3>主动推送</h3>
<div class="row section">
<div>
<label>主开关</label>
<select>
<option>开启</option>
<option>关闭</option>
</select>
</div>
<div>
<label>推送时间</label>
<input class="input" placeholder="20:00" />
</div>
</div>
<div class="section">
<label>触发类型</label>
<div class="chips">
<span class="chip selected">时间触发</span>
<span class="chip selected">事件触发</span>
<span class="chip">行为触发</span>
<span class="chip">成长触发</span>
</div>
</div>
<div class="section">
<label>免打扰</label>
<div class="row">
<input class="input" placeholder="21:00" />
<input class="input" placeholder="09:00" />
</div>
</div>
</div>
<div class="card">
<h3>推送预览</h3>
<div class="callout">“今晚给小明讲一个关于太空的故事,好吗?”</div>
<button class="btn btn--secondary" style="margin-top: 12px;">发送测试推送</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>故事详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="section">
<div class="cover-hero"></div>
<h2 style="margin-top: 16px;">星际冒险 · 勇气的种子</h2>
<div class="card-meta">小明 · 星际冒险宇宙 · 2025/01/12</div>
<div class="hero-actions section">
<button class="btn btn--secondary">重新生成封面</button>
<button class="btn btn--primary">生成语音</button>
<button class="btn btn--ghost">分享</button>
</div>
</div>
<div class="split section">
<div class="card">
<h3>故事正文</h3>
<p>夜空像一条温柔的河流,小明驾驶着飞船穿过星光……</p>
<p>他握紧操纵杆,鼓起勇气,向未知的星球靠近。</p>
<p>最终,小明发现了新的朋友,也学会了如何面对黑暗。</p>
</div>
<div class="card">
<h3>成就</h3>
<div class="chips section">
<span class="chip selected">勇气</span>
<span class="chip selected">友谊</span>
</div>
<div class="callout section">“克服了黑暗的恐惧”</div>
<div class="callout">“帮助了迷路的小伙伴”</div>
</div>
</div>
<div class="section audio-player">
<button class="btn btn--ghost">播放</button>
<div class="audio-bar"><div class="audio-progress"></div></div>
<button class="btn btn--ghost">1.0x</button>
</div>
<div class="footer-note">语音未生成时,显示“生成语音”按钮作为主操作。</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,217 @@
:root {
--primary-600: #7C3AED;
--primary-500: #8B5CF6;
--primary-100: #EDE9FE;
--accent-pink: #FB7185;
--accent-sky: #22D3EE;
--success: #34C759;
--warning: #F6A609;
--error: #FF5A5F;
--neutral-900: #1F2937;
--neutral-700: #4B5563;
--neutral-500: #9CA3AF;
--neutral-200: #E5E7EB;
--neutral-100: #F5F5F7;
--hero-gradient: linear-gradient(135deg, #EDE9FE 0%, #FFE4F3 45%, #E0F7FF 100%);
}
* { box-sizing: border-box; }
:root {
--container-width: 1200px;
--gutter: 24px;
--radius-card: 12px;
--radius-input: 10px;
--radius-button: 8px;
--radius-pill: 24px;
--shadow-s: 0 4px 16px rgba(31,36,48,0.08);
--shadow-m: 0 10px 30px rgba(31,36,48,0.12);
}
body {
margin: 0;
font-family: "PingFang SC", "Noto Sans SC", Inter, system-ui, -apple-system, sans-serif;
color: var(--neutral-900);
background: var(--neutral-100);
}
a { color: var(--primary-600); text-decoration: none; }
.page { min-height: 100vh; }
.container {
width: min(var(--container-width), 100% - 48px);
margin: 0 auto;
}
.nav {
background: #fff;
border-bottom: 1px solid var(--neutral-200);
position: sticky;
top: 0;
z-index: 10;
}
.nav__inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
gap: 16px;
}
.nav__left, .nav__center, .nav__right { display: flex; align-items: center; gap: 16px; }
.nav__logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: var(--neutral-900);
}
.nav__logo-badge {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary-500), var(--accent-sky));
}
.nav__item { color: var(--neutral-700); font-weight: 500; }
.nav__item.active { color: var(--primary-600); }
.hero {
background: var(--hero-gradient);
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-s);
}
.section { margin: 28px 0; }
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.card {
background: #fff;
border-radius: var(--radius-card);
padding: 16px;
box-shadow: var(--shadow-s);
}
.card--flat { box-shadow: none; border: 1px solid var(--neutral-200); }
.card-cover {
width: 100%;
aspect-ratio: 21 / 9;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary-100), #fff 60%, var(--accent-sky));
margin-bottom: 12px;
}
.card-title { font-weight: 600; margin: 6px 0; }
.card-meta { color: var(--neutral-500); font-size: 12px; }
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--primary-100);
color: var(--primary-600);
font-size: 12px;
}
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
padding: 6px 12px;
border-radius: var(--radius-pill);
border: 1px solid var(--neutral-200);
background: #fff;
font-size: 12px;
}
.chip.selected { background: var(--primary-100); border-color: var(--primary-500); color: var(--primary-600); }
.btn {
height: 40px;
padding: 0 16px;
border-radius: var(--radius-button);
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
}
.btn--primary { background: var(--primary-600); color: #fff; }
.btn--secondary { background: #fff; border-color: var(--primary-600); color: var(--primary-600); }
.btn--ghost { background: transparent; color: var(--neutral-700); }
.btn--danger { background: #fff; border-color: var(--error); color: var(--error); }
.input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: var(--radius-input);
border: 1px solid var(--neutral-200);
background: #fff;
font-size: 14px;
}
.row { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; }
.stepper { display: flex; gap: 10px; align-items: center; }
.step {
padding: 6px 12px;
border-radius: var(--radius-pill);
background: var(--neutral-100);
color: var(--neutral-700);
font-size: 12px;
}
.step.active { background: var(--primary-100); color: var(--primary-600); }
.toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid var(--neutral-200); padding: 12px 8px; text-align: left; font-size: 14px; }
.avatar {
width: 40px; height: 40px; border-radius: 50%;
background: var(--primary-100);
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; color: var(--primary-600);
}
.cover-hero {
aspect-ratio: 16 / 9;
border-radius: 14px;
background: linear-gradient(135deg, var(--primary-100), #fff 55%, var(--accent-pink));
}
.audio-player {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
border-radius: 12px; border: 1px solid var(--neutral-200); background: #fff;
}
.audio-bar { height: 6px; background: var(--neutral-200); border-radius: 999px; flex: 1; }
.audio-progress { width: 35%; height: 100%; background: var(--primary-600); border-radius: 999px; }
.tabs { display: flex; gap: 8px; border-bottom: 1px solid var(--neutral-200); }
.tab { padding: 10px 12px; color: var(--neutral-700); }
.tab.active { color: var(--primary-600); border-bottom: 2px solid var(--primary-600); }
.callout {
background: #fff;
border: 1px dashed var(--neutral-200);
border-radius: 12px;
padding: 12px;
color: var(--neutral-700);
font-size: 12px;
}
.footer-note { color: var(--neutral-500); font-size: 12px; margin-top: 12px; }
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.split {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
@media (max-width: 1024px) {
.grid-3 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.split { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.row { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>宇宙详情</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="card section">
<h2>星际冒险</h2>
<div class="card-meta">主角:小明船长 · 更新于 2025/01/12</div>
</div>
<div class="grid grid-2 section">
<div class="card">
<h3>主角设定</h3>
<div class="callout">小明是来自地球的探险家,勇敢且好奇。</div>
</div>
<div class="card">
<h3>常驻角色</h3>
<div class="callout">机器人小七、外星猫咪星星</div>
</div>
<div class="card">
<h3>世界观</h3>
<div class="callout">星际学院、彩虹星云、飞船港湾</div>
</div>
<div class="card">
<h3>成就</h3>
<div class="callout">克服恐惧 · 结交朋友 · 学会独立</div>
</div>
</div>
<div class="section">
<button class="btn btn--secondary">编辑宇宙</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>故事宇宙</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="nav">
<div class="container nav__inner">
<div class="nav__left">
<div class="nav__logo">
<span class="nav__logo-badge"></span>
DreamWeaver
</div>
<span class="badge">Web 原型</span>
</div>
<div class="nav__center">
<a class="nav__item active" href="home.html">生成故事</a>
<a class="nav__item" href="my-stories.html">我的故事</a>
<a class="nav__item" href="child-profiles.html">孩子档案</a>
<a class="nav__item" href="universes.html">故事宇宙</a>
<a class="nav__item" href="push-settings.html">推送设置</a>
<a class="nav__item" href="account-settings.html">账户设置</a>
<a class="nav__item" href="admin-providers.html">管理后台</a>
</div>
<div class="nav__right">
<input class="input" style="width: 200px;" placeholder="搜索故事" />
<div class="avatar"></div>
</div>
</div>
</header>
<div class="container" style="padding: 28px 0 60px;">
<div class="toolbar section">
<h2>故事宇宙</h2>
<button class="btn btn--primary">新建宇宙</button>
</div>
<div class="grid grid-3 section">
<div class="card">
<div class="card-title">星际冒险</div>
<div class="card-meta">主角:小明船长</div>
<div class="chips section">
<span class="chip">伙伴:机器人小七</span>
<span class="chip">成就3</span>
</div>
</div>
<div class="card">
<div class="card-title">梦幻森林</div>
<div class="card-meta">主角:森林守护者</div>
<div class="chips section">
<span class="chip">伙伴:魔法猫咪</span>
<span class="chip">成就1</span>
</div>
</div>
<div class="card card--flat">
<div class="callout">空态示例:创建第一个宇宙</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

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

View File

@@ -0,0 +1,455 @@
# 记忆智能系统 PRD
## 概述
**功能名称**: 记忆智能 (Memory Intelligence)
**版本**: v1.0
**优先级**: Phase 2.5 (体验增强后、社区化前)
**目标用户**: 家长 + 3-8 岁儿童
### 核心价值
让 DreamWeaver 从"故事生成工具"进化为"懂孩子的故事伙伴"
- **记住孩子**: 偏好、成长阶段、兴趣变化
- **延续故事**: 角色、世界观跨故事延续
- **主动关怀**: 适时推送个性化故事建议
---
## 一、功能模块
### 1.1 孩子档案系统 (Child Profile)
| 字段 | 类型 | 说明 |
|------|------|------|
| 基础信息 | 显式 | 姓名、年龄、性别 |
| 兴趣标签 | 显式+隐式 | 恐龙、公主、太空、动物等 |
| 成长主题 | 显式 | 当前关注:勇气/分享/独立等 |
| 阅读偏好 | 隐式 | 故事长度、风格、复杂度 |
| 互动历史 | 隐式 | 喜欢的故事、跳过的故事 |
**数据来源**:
- 显式: 家长主动填写
- 隐式: 系统从使用行为中学习
### 1.2 故事宇宙记忆 (Story Universe)
跨故事保持连续性的元素:
| 元素 | 说明 | 示例 |
|------|------|------|
| 主角设定 | 孩子的故事化身 | "小明是个爱冒险的男孩" |
| 常驻角色 | 反复出现的配角 | 魔法猫咪"星星"、智慧老树 |
| 世界观 | 故事发生的宇宙 | 梦幻森林、星际学院 |
| 成就系统 | 主角的成长轨迹 | "学会了勇敢"、"交到新朋友" |
**记忆结构字段**:
- `protagonist` / `recurring_characters` / `world_settings` / `achievements`JSON 结构)
- “延续上一个故事”默认选最近更新的宇宙(按 `updated_at` 倒序)
### 1.3 主动推送系统 (Proactive Push)
| 触发类型 | 条件 | 推送内容 |
|----------|------|----------|
| 时间触发 | 睡前时段 (19:00-21:00) | "今晚想听什么故事?" |
| 事件触发 | 节日/生日 | 主题故事推荐 |
| 行为触发 | 3天未使用 | 召回提醒 |
| 成长触发 | 年龄变化 | 难度升级建议 |
**优先级与抑制**:
- 优先级:事件 > 成长 > 行为 > 时间
- 抑制当天已推送不再触发静默时段21:00-09:00延迟用户关闭推送则不触发
---
## 二、用户故事
### US-1: 创建孩子档案
```
作为家长
我想要创建孩子的专属档案
以便系统生成更适合孩子的故事
```
**验收标准**:
- [ ] 可填写孩子基础信息(姓名、年龄、性别)
- [ ] 可选择兴趣标签(多选)
- [ ] 可设置当前成长主题
- [ ] 支持多个孩子档案切换
### US-2: 故事角色延续
```
作为家长
我想要故事中的角色能在新故事中再次出现
以便孩子感受到故事的连续性
```
**验收标准**:
- [ ] 生成故事时可选择"延续上一个故事"
- [ ] 系统自动带入主角设定和常驻角色
- [ ] 新故事引用之前的"成就"
### US-3: 睡前故事提醒
```
作为家长
我想要在睡前时段收到故事推荐
以便养成固定的亲子阅读习惯
```
**验收标准**:
- [ ] 可设置提醒时间
- [ ] 推送包含个性化故事建议
- [ ] 可一键进入故事生成
---
## 三、数据模型
### 3.1 孩子档案表 (child_profiles)
```sql
CREATE TABLE child_profiles (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
name VARCHAR(50) NOT NULL,
birth_date DATE,
gender VARCHAR(10),
interests JSONB DEFAULT '[]',
growth_themes JSONB DEFAULT '[]',
reading_preferences JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### 3.2 故事宇宙表 (story_universes)
```sql
CREATE TABLE story_universes (
id UUID PRIMARY KEY,
child_profile_id UUID REFERENCES child_profiles(id),
name VARCHAR(100) NOT NULL,
protagonist JSONB NOT NULL,
recurring_characters JSONB DEFAULT '[]',
world_settings JSONB DEFAULT '{}',
achievements JSONB DEFAULT '[]',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### 3.3 推送配置表 (push_configs)
```sql
CREATE TABLE push_configs (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
child_profile_id UUID REFERENCES child_profiles(id),
push_time TIME,
push_days INTEGER[], -- 0-6 表示周日到周六
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 3.4 推送事件表 (push_events)
```sql
CREATE TABLE push_events (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
child_profile_id UUID NOT NULL,
trigger_type VARCHAR(20) NOT NULL, -- time/event/behavior/growth
sent_at TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL, -- sent/failed/suppressed
reason TEXT
);
```
### 3.5 记忆条目表 (memory_items)
用于存储“可解释、可控”的记忆条目(兴趣偏好、成长主题、常驻角色、关键事件等),并支持时序衰减。
```sql
CREATE TABLE memory_items (
id UUID PRIMARY KEY,
child_profile_id UUID NOT NULL,
universe_id UUID,
type VARCHAR(50) NOT NULL, -- interest/growth/character/event等
value JSONB NOT NULL, -- 结构化内容
base_weight FLOAT DEFAULT 1.0, -- 初始权重
last_used_at TIMESTAMP, -- 最近使用时间
created_at TIMESTAMP DEFAULT NOW(),
ttl_days INTEGER -- 可选:过期天数
);
```
---
## 四、API 设计
### 4.1 孩子档案 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/profiles` | 获取当前用户的所有孩子档案 |
| POST | `/api/profiles` | 创建孩子档案 |
| GET | `/api/profiles/{id}` | 获取单个档案详情 |
| PUT | `/api/profiles/{id}` | 更新档案 |
| DELETE | `/api/profiles/{id}` | 删除档案 |
### 4.2 故事宇宙 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/profiles/{id}/universes` | 获取孩子的故事宇宙列表 |
| POST | `/api/profiles/{id}/universes` | 创建新宇宙 |
| GET | `/api/universes/{id}` | 获取宇宙详情 |
| PUT | `/api/universes/{id}` | 更新宇宙设定 |
| POST | `/api/universes/{id}/achievements` | 添加成就 |
### 4.3 推送配置 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/push-configs` | 获取推送配置 |
| PUT | `/api/push-configs` | 更新推送配置 |
---
## 五、Prompt 工程
### 5.1 带记忆的故事生成 Prompt
```
你是一个专业的儿童故事作家。请为以下孩子创作一个故事:
【孩子档案】
- 姓名: {child_name}
- 年龄: {age}岁
- 兴趣: {interests}
- 当前成长主题: {growth_theme}
【故事宇宙】
- 主角设定: {protagonist}
- 常驻角色: {recurring_characters}
- 世界观: {world_settings}
- 已获成就: {achievements}
【本次创作要求】
- 关键词: {keywords}
- 延续之前的故事世界观
- 让主角在故事中有新的成长
请创作一个适合{age}岁儿童的故事,约{word_count}字。
```
### 5.2 成就提取 Prompt
```
请分析以下故事,提取主角获得的成长/成就:
【故事内容】
{story_content}
请以JSON格式返回
{
"achievements": [
{"type": "勇气", "description": "克服了对黑暗的恐惧"},
{"type": "友谊", "description": "帮助了迷路的小兔子"}
]
}
```
---
## 六、前端设计
### 6.1 孩子档案页面
```
┌─────────────────────────────────────┐
│ 我的宝贝 [+添加] │
├─────────────────────────────────────┤
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 👦 │ │ 👧 │ │ + │ │
│ │小明 │ │小红 │ │添加 │ │
│ │5岁 │ │3岁 │ │ │ │
│ └─────┘ └─────┘ └─────┘ │
└─────────────────────────────────────┘
```
### 6.2 档案详情页
```
┌─────────────────────────────────────┐
│ ← 小明的档案 [编辑] │
├─────────────────────────────────────┤
│ 基础信息 │
│ 姓名: 小明 年龄: 5岁 性别: 男 │
├─────────────────────────────────────┤
│ 兴趣爱好 │
│ [恐龙] [太空] [机器人] │
├─────────────────────────────────────┤
│ 成长主题 │
│ ○ 勇气 ● 分享 ○ 独立 ○ 友谊 │
├─────────────────────────────────────┤
│ 故事宇宙 │
│ ┌─────────────────────────────┐ │
│ │ 🌟 星际冒险 │ │
│ │ 主角: 小明船长 │ │
│ │ 伙伴: 机器人小七、外星猫咪 │ │
│ │ 成就: 3个 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
```
### 6.3 故事生成时选择档案
```
┌─────────────────────────────────────┐
│ 为谁创作故事? │
├─────────────────────────────────────┤
│ ● 小明 (5岁) │
│ ○ 小红 (3岁) │
│ ○ 不使用档案 │
├─────────────────────────────────────┤
│ 选择故事宇宙 │
│ ● 星际冒险 (延续上次) │
│ ○ 创建新宇宙 │
├─────────────────────────────────────┤
│ [下一步: 输入关键词] │
└─────────────────────────────────────┘
```
---
## 七、技术实现要点
### 7.1 隐式偏好学习
```python
# 基于用户行为更新偏好
async def update_implicit_preferences(
child_id: UUID,
story: Story,
interaction: Interaction # 完整阅读/跳过/重复播放
):
profile = await get_child_profile(child_id)
if interaction == "completed":
# 增加相关标签权重
for tag in story.tags:
profile.reading_preferences[tag] = \
profile.reading_preferences.get(tag, 0) + 1
elif interaction == "skipped":
# 降低相关标签权重
for tag in story.tags:
profile.reading_preferences[tag] = \
profile.reading_preferences.get(tag, 0) - 0.5
```
### 7.2 成就自动提取
故事生成完成后,异步调用 LLM 提取成就(以 `type + description` 去重):
```python
@celery.task
async def extract_achievements(story_id: UUID, universe_id: UUID):
story = await get_story(story_id)
universe = await get_universe(universe_id)
achievements = await llm.extract_achievements(story.content)
universe.achievements.extend(achievements)
await save_universe(universe)
```
### 7.3 推送调度
使用 Celery Beat 定时检查推送:
```python
@celery.task
def check_push_notifications():
current_time = datetime.now().time()
current_day = datetime.now().weekday()
configs = PushConfig.query.filter(
PushConfig.enabled == True,
PushConfig.push_time <= current_time,
current_day.in_(PushConfig.push_days)
).all()
for config in configs:
send_push_notification.delay(config.user_id, config.child_profile_id)
```
**执行约束**:
- 同一孩子每天最多 1 次推送
- 推送前查询 `push_events` 去重,成功/抑制均需记录
### 7.4 时序衰减与记忆评分
**目标**:让“越新的记忆影响越大”,避免旧偏好长期干扰。
**默认实现(推荐)**:查询时动态计算分数,不直接修改数据库。
- 记忆分数:`score = base_weight × decay(Δt)`
- 衰减示例分段0-7 天 1.08-30 天 0.731-90 天 0.490 天后 0.2
- 读取时按 `score` 排序,选 Top N 进入 Prompt
**可选实现**:定期批处理降权
- 每日/每周批量更新 `base_weight`
- 适合数据量大、读多写少的场景
**RAG 场景的衰减用法**
- 语义相似度分数 × 时间衰减
- 可加时间窗口过滤(如仅取最近 90 天)
**删除策略(默认不删)**
- 默认只降权,不主动删除
- 可选:对低权重且 180 天未使用的条目执行 TTL 清理
---
## 八、里程碑
### M1: 孩子档案基础
- [ ] 数据库模型
- [ ] CRUD API
- [ ] 前端档案管理页面
- [ ] 故事生成时选择档案
### M2: 故事宇宙
- [ ] 宇宙数据模型
- [ ] Prompt 集成
- [ ] 成就自动提取
- [ ] 前端宇宙管理
### M3: 主动推送
- [ ] 推送配置 API
- [ ] Celery Beat 调度
- [ ] 推送通知集成 (Web Push / 微信)
### M4: 隐式学习
- [ ] 行为埋点
- [ ] 偏好学习算法
- [ ] 推荐优化
---
## 九、风险与应对
| 风险 | 影响 | 应对 |
|------|------|------|
| 隐私合规 | 高 | 儿童数据加密存储,家长授权机制 |
| 推送骚扰 | 中 | 默认关闭,用户主动开启 |
| 记忆膨胀 | 低 | 定期清理旧数据,限制宇宙数量 |
---
## 十、相关文档
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)
- [故事宇宙记忆结构](./STORY-UNIVERSE-MODEL.md)
- [主动推送触发规则](./PUSH-TRIGGER-RULES.md)

View File

@@ -0,0 +1,177 @@
# 记忆与个性化技术方案建议PRD 讨论稿)
> 目标:给 DreamWeaver 的“记忆与个性化”提供可落地的技术路径与产品取舍依据,用于 PRD 细化。
---
## 1. 总体结论(推荐方案)
**v1 推荐:混合方案(结构化 DB + 轻量语义检索)**
- **DB** 作为权威事实与可解释记忆(孩子档案、宇宙设定、成就、偏好权重)。
- **RAG** 用于非结构化内容(故事摘要、互动摘要、近期期望),辅助个性化提示词。
**原因**
- 纯 DB 可控但缺乏语义弹性;纯 RAG 难以稳定控制与审计。
- 混合方案能在“可解释 + 个性化”之间取到最佳平衡。
---
## 2. DB vs RAG技术与产品对比
### 2.1 DB结构化记忆
**适用内容**
- 孩子档案(基础信息)
- 兴趣标签与成长主题
- 故事宇宙设定(主角、世界观、常驻角色)
- 成就(可审核、可追溯)
**优点**
- 高可解释性
- 变更可追踪、可回滚
- 便于用户管理(家长可编辑)
**缺点**
- 灵活性不足
- 难以覆盖“隐性偏好”(比如叙事风格喜好)
### 2.2 RAG语义记忆
**适用内容**
- 故事摘要
- 互动摘要(“最近更喜欢冒险故事”)
- 非结构化日志
**优点**
- 具备语义召回能力
- 适合挖掘“隐含偏好”
**缺点**
- 可解释性弱
- 成本与性能压力大
- 隐私风险更高
---
## 3. 时序性与记忆衰减(建议必须有)
**核心观点**:孩子兴趣会随时间变化,必须引入时间衰减。
**做法建议**
- 所有记忆项带 `created_at` / `last_used_at`
- 引入权重衰减模型:
- 近 7 天:高权重
- 30 天:中权重
- 90 天:低权重
- 超过 90 天:降权或淘汰
**价值**
- 避免旧偏好过度影响新故事
- 体现成长与兴趣演变
---
## 4. 分层记忆(建议引入)
建议采用三层结构:
### 4.1 短期记忆Session
- 当前生成上下文(关键词、选定档案/宇宙)
- 生命周期:仅本次请求有效
### 4.2 中期记忆(近期偏好)
- 最近 5-10 次故事生成/阅读偏好
- 生命周期30-60 天
### 4.3 长期记忆(稳定事实)
- 档案、宇宙、核心兴趣
- 生命周期:长期可编辑
**价值**
- 既保留稳定设定,又能捕捉近期变化
---
## 5. Agent 动态判断是否写入记忆
**建议:规则优先 + 模型辅助**
流程示例:
1. 命中规则(如完整阅读/重复播放)→ 进入候选
2. LLM 抽取结构化信息 + 置信度
3. 置信度不足 → 不写入
**优点**
- 避免模型“乱记忆”
- 降低噪声,提高记忆质量
---
## 6. 推荐的记忆数据结构
### 6.1 结构化表DB
- `child_profiles`:基础信息、兴趣、成长主题
- `story_universes`:主角、角色、世界观、成就
- `reading_events`:阅读/跳过/重播行为日志
- `memory_items`抽象记忆表type, value, confidence, ttl
### 6.2 语义检索RAG
- 存储内容:故事摘要、成就摘要、行为总结
- 向量库:**pgvector**(成本低、易部署)
- 检索过滤:`child_id` / `universe_id` / 时间窗口
---
## 7. 关键产品问题(需明确)
1) **记忆是否可编辑**
- 家长是否能查看、修改、删除系统记忆?
2) **跨孩子隔离**
- 同账号多孩子的记忆是否完全隔离(推荐隔离)
3) **隐私与合规**
- 哪些数据进入记忆?是否脱敏?是否加密?
4) **性能与成本**
- RAG 查询是否影响生成时延?
- 是否需要缓存与批量检索?
5) **效果评估**
- 记忆是否提高故事满意度?
- 需要 A/B 或指标体系吗?
---
## 8. 推荐实施路线
### v11-2 个月)
- DB 记忆为主RAG 只做轻量补充
- 引入时序衰减
- 记忆来源:用户显式输入 + 行为日志
### v22-3 个月)
- 引入 Agent 记忆抽取与置信度
- 记忆管理界面(家长可编辑)
- 更精细的个性化推荐
---
## 9. 需要确认的决定点
- 是否采用混合方案DB + RAG
- RAG 的检索范围(故事摘要 / 行为摘要 / 成就)
- 记忆分层与衰减规则
- Agent 记忆写入规则与阈值
- 家长可见/可控的记忆管理策略
---
如确认以上方向,我可以进一步输出:
- PRD 里的“记忆系统”完整章节
- 数据模型(含字段 + 时序衰减)
- 交互与界面草案
- 后端实现拆解(任务清单 + 里程碑)

View File

@@ -0,0 +1,129 @@
# 主动推送触发规则
## 概述
主动推送用于在合适的时间为家长提供个性化故事建议,提升使用频次与亲子阅读习惯。推送默认关闭,需家长开启并配置时间。
---
## 一、数据输入
- **孩子档案**: `child_profiles`(年龄、兴趣、成长主题)
- **故事数据**: `stories`(最近生成/阅读时间、主题标签)
- **推送配置**: `push_configs`(时间、周期、开关)
- **节日与生日**: 预置日历 + `birth_date`
- **行为事件**: 阅读/播放/跳过等行为埋点
---
## 二、触发类型与规则
### 2.1 时间触发(睡前)
- 条件:当前时间落在用户设定 `push_time` 附近(建议 ±30 分钟)。
- 频率:同一孩子每天最多 1 次。
- 示例19:00-21:00 之间推送“今晚想听什么故事?”
### 2.2 事件触发(节日/生日)
- 条件:
- 生日:`birth_date` 月日与当天一致。
- 节日:命中节日清单(如儿童节、中秋节等)。
- 频率:当天仅推送 1 次,优先级高于时间触发。
### 2.3 行为触发(召回)
- 条件:最近 3 天无故事生成或阅读行为。
- 频率:每 3 天最多 1 次,避免频繁打扰。
### 2.4 成长触发(年龄变化)
- 条件:年龄跨越关键节点(如 4→5 岁)。
- 频率:每次年龄变化仅触发一次。
- 目的:推荐难度升级或新的成长主题。
---
## 三、优先级与抑制规则
**优先级顺序**(从高到低):
1. 事件触发
2. 成长触发
3. 行为触发
4. 时间触发
**抑制规则**:
- 当天已推送则不再触发其他类型。
- 若在静默时间21:00-09:00触发则延迟至下一个允许窗口。
- 用户关闭推送或未配置推送时间时,不触发。
---
## 四、个性化内容策略
- **兴趣标签**: 引用孩子的兴趣标签生成主题。
- **成长主题**: 优先匹配当前成长主题。
- **历史偏好**: 参考最近故事的标签与完成度。
**示例模板**:
- “今晚给{child_name}讲一个关于{interest}的故事,好吗?”
- “{child_name}最近在学习{growth_theme},我准备了一个新故事。”
---
## 五、调度实现建议
使用 Celery Beat 每 5-10 分钟执行一次规则检查:
```python
@celery.task
def check_push_notifications():
now = datetime.now(local_tz)
configs = get_enabled_configs(now)
for config in configs:
if has_sent_today(config.child_profile_id):
continue
trigger = select_trigger(config, now)
if trigger:
send_push_notification(config.user_id, config.child_profile_id, trigger)
```
**关键点**:
- 需要记录每日推送日志用于去重。
- 优先级触发时应立即标记已发送。
---
## 六、日志与度量
建议增加 `push_events` 事件表用于统计与去重:
```sql
CREATE TABLE push_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
child_profile_id UUID NOT NULL,
trigger_type VARCHAR(20) NOT NULL, -- time/event/behavior/growth
sent_at TIMESTAMP WITH TIME ZONE NOT NULL,
status VARCHAR(20) NOT NULL, -- sent/failed/suppressed
reason TEXT
);
```
核心指标:
- Push 发送成功率
- 打开率CTA 点击)
- 触发分布占比
---
## 七、安全与合规
- **默认关闭**,需家长显式开启。
- 支持一键关闭或设定免打扰时段。
- 遵循儿童隐私合规要求,最小化推送内容敏感信息。
---
## 八、相关文档
- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md)
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)

View File

@@ -0,0 +1,231 @@
# 故事宇宙记忆结构
## 概述
故事宇宙用于在多次故事生成中保持角色、世界观与成长成就的连续性。每个孩子档案可以拥有多个宇宙,故事生成时可选择“延续上一个故事”,系统自动带入宇宙设定。
---
## 一、数据库模型
### 1.1 主表: story_universes
```sql
CREATE TABLE story_universes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
child_profile_id UUID NOT NULL REFERENCES child_profiles(id) ON DELETE CASCADE,
-- 宇宙基础
name VARCHAR(100) NOT NULL,
-- 记忆结构
protagonist JSONB NOT NULL, -- 主角设定
recurring_characters JSONB DEFAULT '[]',
world_settings JSONB DEFAULT '{}',
achievements JSONB DEFAULT '[]',
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_story_universes_child_id ON story_universes(child_profile_id);
CREATE INDEX idx_story_universes_updated_at ON story_universes(updated_at);
```
### 1.2 JSON 结构示例
**protagonist**
```json
{
"name": "小明",
"role": "星际船长",
"traits": ["勇敢", "好奇"],
"goal": "寻找失落的星球",
"backstory": "来自地球的探险家"
}
```
**recurring_characters**
```json
[
{
"name": "星星",
"role": "魔法猫咪",
"traits": ["聪明", "调皮"],
"relation": "伙伴",
"first_story_id": "story-uuid"
}
]
```
**world_settings**
```json
{
"world_name": "梦幻森林",
"era": "童话时代",
"locations": ["彩虹河", "月光山"],
"rules": ["动物会说话", "星星会指路"],
"tone": "温暖治愈"
}
```
**achievements**
```json
[
{
"type": "勇气",
"description": "克服了对黑暗的恐惧",
"story_id": "story-uuid",
"achieved_at": "2025-01-10T12:00:00Z"
}
]
```
---
## 二、SQLAlchemy 模型
```python
# backend/app/db/models.py
from sqlalchemy import Column, String, ForeignKey, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
import uuid
class StoryUniverse(Base):
__tablename__ = "story_universes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
child_profile_id = Column(UUID(as_uuid=True), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False)
name = Column(String(100), nullable=False)
protagonist = Column(JSON, nullable=False)
recurring_characters = Column(JSON, default=list)
world_settings = Column(JSON, default=dict)
achievements = Column(JSON, default=list)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
child_profile = relationship("ChildProfile", back_populates="story_universes")
```
---
## 三、Pydantic Schema
```python
# backend/app/schemas/story_universe.py
from pydantic import BaseModel, Field
from typing import Any
from uuid import UUID
class StoryUniverseCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
protagonist: dict[str, Any]
recurring_characters: list[dict[str, Any]] = Field(default_factory=list)
world_settings: dict[str, Any] = Field(default_factory=dict)
class StoryUniverseUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
protagonist: dict[str, Any] | None = None
recurring_characters: list[dict[str, Any]] | None = None
world_settings: dict[str, Any] | None = None
class StoryUniverseResponse(BaseModel):
id: UUID
child_profile_id: UUID
name: str
protagonist: dict[str, Any]
recurring_characters: list[dict[str, Any]]
world_settings: dict[str, Any]
achievements: list[dict[str, Any]]
class Config:
from_attributes = True
```
---
## 四、API 约定
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/profiles/{id}/universes` | 获取孩子的故事宇宙列表 |
| POST | `/api/profiles/{id}/universes` | 创建新宇宙 |
| GET | `/api/universes/{id}` | 获取宇宙详情 |
| PUT | `/api/universes/{id}` | 更新宇宙设定 |
| POST | `/api/universes/{id}/achievements` | 添加成就 |
---
## 五、业务规则
- **延续故事**: “延续上一个故事”默认选最近更新的宇宙(按 `updated_at` 倒序)。
- **成就追加**: 新成就追加到 `achievements`,以 `type + description` 去重。
- **成长轨迹**: 成就保留顺序,优先展示最新项。
---
## 六、Prompt 集成
当选择宇宙时,生成 Prompt 需带入宇宙记忆:
```
【故事宇宙】
- 主角设定: {protagonist}
- 常驻角色: {recurring_characters}
- 世界观: {world_settings}
- 已获成就: {achievements}
```
未选择宇宙时,提示词忽略该块,避免混淆。
---
## 七、数据迁移示例
```python
# backend/alembic/versions/xxx_add_story_universes.py
def upgrade():
op.create_table(
"story_universes",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("child_profile_id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("protagonist", sa.JSON(), nullable=False),
sa.Column("recurring_characters", sa.JSON(), server_default='[]'),
sa.Column("world_settings", sa.JSON(), server_default='{}'),
sa.Column("achievements", sa.JSON(), server_default='[]'),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["child_profile_id"], ["child_profiles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_story_universes_child_id", "story_universes", ["child_profile_id"])
op.create_index("idx_story_universes_updated_at", "story_universes", ["updated_at"])
def downgrade():
op.drop_index("idx_story_universes_updated_at")
op.drop_index("idx_story_universes_child_id")
op.drop_table("story_universes")
```
---
## 八、权限与安全
- 宇宙数据必须通过 `child_profile_id` 归属校验,确保仅拥有者可访问。
- 删除用户或档案时,级联删除所有宇宙数据。
---
## 九、相关文档
- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md)
- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md)

View File

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

View File

@@ -0,0 +1,677 @@
# RFC: 供应商平台化架构设计
## 背景
### 当前问题
1. **硬编码适配器**: `gemini`, `flux`, `minimax` 写死在代码中
2. **新供应商需改代码**: 接入 nanobanana 等新供应商需要修改 `provider_router.py`
3. **无法动态切换**: 供应商故障时需要重启服务
4. **缺乏监控**: 不知道哪个供应商更快、更便宜、更稳定
### 目标
- **零代码接入**: 通过后台配置即可接入新供应商
- **动态切换**: 运行时切换供应商,无需重启
- **智能路由**: 基于成本、延迟、成功率自动选择最优供应商
- **可观测性**: 供应商健康状态、成本、性能一目了然
---
## 架构设计
### 1. 整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ Admin Dashboard │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 供应商管理 │ │ 健康监控 │ │ 成本分析 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Provider Router │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 路由策略: Priority → Weight → Health → Cost │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────┬───────────┬───────────┬───────────────────┐ │
│ │ Adapter │ Adapter │ Adapter │ Adapter │ │
│ │ Registry │ Factory │ Health │ Metrics │ │
│ └───────────┴───────────┴───────────┴───────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Text Adapters │ │ Image Adapters│ │ TTS Adapters │
├───────────────┤ ├───────────────┤ ├───────────────┤
│ • Gemini │ │ • Flux │ │ • Minimax │
│ • OpenAI │ │ • Nanobanana │ │ • ElevenLabs │
│ • Claude │ │ • DALL-E │ │ • Azure TTS │
│ • Qwen │ │ • Midjourney │ │ • Google TTS │
└───────────────┘ └───────────────┘ └───────────────┘
```
### 2. 核心组件
#### 2.1 Adapter 接口定义
```python
# 统一适配器接口
from abc import ABC, abstractmethod
from typing import TypeVar, Generic
from pydantic import BaseModel
T = TypeVar("T")
class AdapterConfig(BaseModel):
"""适配器配置基类"""
api_key: str
api_base: str | None = None
model: str | None = None
timeout_ms: int = 60000
max_retries: int = 3
class BaseAdapter(ABC, Generic[T]):
"""适配器基类"""
# 适配器元信息
adapter_type: str # text / image / tts
adapter_name: str # gemini / flux / minimax
def __init__(self, config: AdapterConfig):
self.config = config
@abstractmethod
async def execute(self, **kwargs) -> T:
"""执行适配器逻辑"""
pass
@abstractmethod
async def health_check(self) -> bool:
"""健康检查"""
pass
@property
@abstractmethod
def estimated_cost(self) -> float:
"""预估单次调用成本 (USD)"""
pass
```
#### 2.2 适配器注册表
```python
# 适配器注册表 - 支持动态注册
class AdapterRegistry:
"""适配器注册表"""
_adapters: dict[str, type[BaseAdapter]] = {}
@classmethod
def register(cls, adapter_type: str, adapter_name: str):
"""装饰器: 注册适配器"""
def decorator(adapter_class: type[BaseAdapter]):
key = f"{adapter_type}:{adapter_name}"
cls._adapters[key] = adapter_class
return adapter_class
return decorator
@classmethod
def get(cls, adapter_type: str, adapter_name: str) -> type[BaseAdapter] | None:
key = f"{adapter_type}:{adapter_name}"
return cls._adapters.get(key)
@classmethod
def list_adapters(cls, adapter_type: str | None = None) -> list[str]:
"""列出所有已注册的适配器"""
if adapter_type:
return [k for k in cls._adapters if k.startswith(f"{adapter_type}:")]
return list(cls._adapters.keys())
```
#### 2.3 适配器实现示例
```python
# 图像适配器示例: Nanobanana
@AdapterRegistry.register("image", "nanobanana")
class NanobananapAdapter(BaseAdapter[str]):
adapter_type = "image"
adapter_name = "nanobanana"
async def execute(self, prompt: str, **kwargs) -> str:
"""生成图片,返回 URL"""
async with httpx.AsyncClient(timeout=self.config.timeout_ms / 1000) as client:
response = await client.post(
f"{self.config.api_base}/generate",
json={"prompt": prompt, "model": self.config.model},
headers={"Authorization": f"Bearer {self.config.api_key}"},
)
response.raise_for_status()
return response.json()["image_url"]
async def health_check(self) -> bool:
# 简单的健康检查
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(f"{self.config.api_base}/health")
return response.status_code == 200
except Exception:
return False
@property
def estimated_cost(self) -> float:
return 0.02 # $0.02 per image
```
#### 2.4 智能路由器
```python
class ProviderRouter:
"""智能供应商路由器"""
def __init__(self, db: AsyncSession):
self.db = db
self._health_cache: dict[str, tuple[bool, float]] = {} # adapter_key -> (healthy, last_check)
async def route(
self,
provider_type: str,
strategy: str = "priority", # priority / cost / latency / round_robin
**kwargs
):
"""路由到最优供应商"""
providers = await self._get_enabled_providers(provider_type)
if not providers:
raise ValueError(f"No {provider_type} providers configured")
# 按策略排序
sorted_providers = self._sort_by_strategy(providers, strategy)
errors = []
for provider in sorted_providers:
# 检查健康状态
if not await self._is_healthy(provider):
continue
try:
adapter = self._create_adapter(provider)
result = await adapter.execute(**kwargs)
# 记录成功指标
await self._record_metrics(provider, success=True)
return result
except Exception as e:
errors.append(f"{provider.name}: {e}")
await self._record_metrics(provider, success=False, error=str(e))
continue
raise ValueError(f"All providers failed: {' | '.join(errors)}")
def _sort_by_strategy(self, providers: list[Provider], strategy: str) -> list[Provider]:
if strategy == "priority":
return sorted(providers, key=lambda p: (-p.priority, -p.weight))
elif strategy == "cost":
return sorted(providers, key=lambda p: self._get_estimated_cost(p))
elif strategy == "latency":
return sorted(providers, key=lambda p: self._get_avg_latency(p))
else:
return providers
```
### 3. 数据模型扩展
```sql
-- 供应商表 (已有,需扩展)
ALTER TABLE providers ADD COLUMN api_key_ref VARCHAR(100); -- 密钥引用 (从 secrets 表获取)
ALTER TABLE providers ADD COLUMN request_schema JSONB; -- 请求参数 schema
ALTER TABLE providers ADD COLUMN response_parser VARCHAR(200); -- 响应解析规则
-- 供应商指标表 (新增)
CREATE TABLE provider_metrics (
id SERIAL PRIMARY KEY,
provider_id VARCHAR(36) REFERENCES providers(id),
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
success BOOLEAN,
latency_ms INTEGER,
cost_usd DECIMAL(10, 6),
error_message TEXT,
request_id VARCHAR(100)
);
-- 供应商健康状态表 (新增)
CREATE TABLE provider_health (
provider_id VARCHAR(36) PRIMARY KEY REFERENCES providers(id),
is_healthy BOOLEAN DEFAULT TRUE,
last_check TIMESTAMP WITH TIME ZONE,
consecutive_failures INTEGER DEFAULT 0,
last_error TEXT
);
-- 密钥管理表 (新增)
CREATE TABLE provider_secrets (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
encrypted_value TEXT NOT NULL, -- 加密存储
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
### 4. Admin Dashboard 功能
#### 4.1 供应商管理
- 供应商列表 (启用/禁用/删除)
- 新增供应商 (选择适配器类型 + 配置参数)
- 编辑供应商 (修改优先级/权重/超时等)
- 测试连接 (验证 API Key 有效性)
#### 4.2 健康监控
- 实时健康状态 (绿/黄/红)
- 成功率趋势图
- 延迟分布图
- 故障告警配置
#### 4.3 成本分析
- 按供应商统计调用量
- 按供应商统计成本
- 成本趋势图
- 预算告警
#### 4.4 A/B 测试
- 创建实验 (供应商 A vs B)
- 流量分配 (50/50 或自定义)
- 效果对比 (成功率/延迟/成本)
---
## 实现路径
### 阶段 1: 适配器抽象 (基础) - ✅ 已完成
| 任务 | 状态 | 文件 |
|------|------|------|
| 定义 `BaseAdapter` 接口 | ✅ | `services/adapters/base.py` |
| 实现 `AdapterRegistry` 注册表 | ✅ | `services/adapters/registry.py` |
| 重构 GeminiAdapter | ✅ | `services/adapters/text/gemini.py` |
| 重构 FluxAdapter | ✅ | `services/adapters/image/flux.py` |
| 重构 MinimaxAdapter | ✅ | `services/adapters/tts/minimax.py` |
| 重构 `ProviderRouter` 使用新接口 | ✅ | `services/provider_router.py` |
### 阶段 2: 新供应商接入 (扩展) - 待开始
1. 实现 Nanobanana 适配器
2. 实现 OpenAI/Claude 文本适配器
3. 实现 ElevenLabs TTS 适配器
4. 验证零代码接入流程
### 阶段 3: 监控与分析 (可观测) - 待开始
1. 实现指标收集
2. 实现健康检查
3. 实现成本追踪
4. Admin Dashboard 开发
### 阶段 4: 智能路由 (优化) - 待开始
1. 实现多种路由策略
2. 实现自动故障转移
3. 实现 A/B 测试框架
---
## 并行执行与容错设计
### 问题
当前串行流程存在两个问题:
1. **等待时间长**: 故事(3-5s) → 封面(5-10s) → 音频(3-5s) = 总计 11-20s
2. **单点失败**: 某一步502/超时导致整个流程失败
### 方案 1: 并行执行
```python
async def generate_story_full(keywords: list[str]) -> StoryResult:
# Step 1: 故事生成(必须先完成,后续依赖它)
story = await generate_story_content(keywords)
# Step 2: 图片和音频并行执行
image_task = asyncio.create_task(generate_image(story.summary))
audio_task = asyncio.create_task(text_to_speech(story.content))
# 等待两者完成,互不阻塞
image_result, audio_result = await asyncio.gather(
image_task, audio_task,
return_exceptions=True # 一个<E4B880><E4B8AA><EFBFBD>败不影响另一个
)
return StoryResult(
story=story,
image_url=image_result if not isinstance(image_result, Exception) else None,
audio_url=audio_result if not isinstance(audio_result, Exception) else None,
errors={
"image": str(image_result) if isinstance(image_result, Exception) else None,
"audio": str(audio_result) if isinstance(audio_result, Exception) else None,
}
)
```
**时间对比:**
```
串行: 3s + 8s + 4s = 15s
并行: 3s + max(8s, 4s) = 11s (节省 27%)
```
### 方案 2: 部分成功处理
**核心原则: 部分成功 > 全部失败**
```python
@dataclass
class StoryResult:
story: Story # 核心,必须成功
image_url: str | None = None # 增强,可降级
audio_url: str | None = None # 增强,可降级
errors: dict[str, str] = field(default_factory=dict)
@property
def is_complete(self) -> bool:
return self.image_url is not None and self.audio_url is not None
@property
def failed_components(self) -> list[str]:
return [k for k, v in self.errors.items() if v is not None]
```
**降级策略:**
| 组件 | 失败时降级方案 | 用户体验 |
|------|---------------|---------|
| 故事 | 无降级,整体失败 | 显示错误,提示重试 |
| 封面 | 使用默认封面图 | 显示占位图 + "重新生成"按钮 |
| 音频 | 不生成音频 | 隐藏播放按钮 + "生成语音"按钮 |
### 方案 3: 流式返回 (SSE)
**为什么用 SSE:**
- 用户无需等待全部完成
- 每完成一步立即展示
- 比 WebSocket 简单HTTP 兼容性好
**后端实现:**
```python
from fastapi import APIRouter
from sse_starlette.sse import EventSourceResponse
router = APIRouter()
@router.post("/api/generate/stream")
async def generate_story_stream(
request: GenerateRequest,
current_user: User = Depends(get_current_user),
):
async def event_generator():
# 1. 立即返回任务ID
story_id = str(uuid.uuid4())
yield {"event": "started", "data": json.dumps({"story_id": story_id})}
# 2. 生成故事
try:
story = await generate_story_content(request.keywords)
yield {"event": "story_ready", "data": json.dumps({
"title": story.title,
"content": story.content,
})}
except Exception as e:
yield {"event": "story_failed", "data": json.dumps({"error": str(e)})}
return
# 3. 并行生成图片和音频
async def gen_image():
try:
url = await generate_image(story.summary)
yield {"event": "image_ready", "data": json.dumps({"image_url": url})}
except Exception as e:
yield {"event": "image_failed", "data": json.dumps({"error": str(e)})}
async def gen_audio():
try:
url = await text_to_speech(story.content)
yield {"event": "audio_ready", "data": json.dumps({"audio_url": url})}
except Exception as e:
yield {"event": "audio_failed", "data": json.dumps({"error": str(e)})}
# 并行执行逐个yield结果
tasks = [gen_image(), gen_audio()]
for coro in asyncio.as_completed([t.__anext__() for t in tasks]):
result = await coro
yield result
yield {"event": "complete", "data": json.dumps({"story_id": story_id})}
return EventSourceResponse(event_generator())
```
**前端实现:**
```typescript
const eventSource = new EventSource('/api/generate/stream', {
method: 'POST',
body: JSON.stringify({ keywords }),
});
eventSource.addEventListener('started', (e) => {
const { story_id } = JSON.parse(e.data);
showLoading('正在创作故事...');
});
eventSource.addEventListener('story_ready', (e) => {
const { title, content } = JSON.parse(e.data);
renderStory(title, content);
showLoading('正在生成封面和语音...');
});
eventSource.addEventListener('image_ready', (e) => {
const { image_url } = JSON.parse(e.data);
renderCover(image_url);
});
eventSource.addEventListener('image_failed', (e) => {
showRetryButton('image');
});
eventSource.addEventListener('audio_ready', (e) => {
const { audio_url } = JSON.parse(e.data);
enablePlayButton(audio_url);
});
eventSource.addEventListener('complete', () => {
eventSource.close();
hideLoading();
});
```
**用户体验时间线:**
```
0s → 显示"正在创作..."
3s → 故事文本渲染,显示"正在生成封面和语音..."
3-7s → 音频就绪,播放按钮可用
3-11s → 封面就绪,图片显示
11s → 完成
```
### 方案 4: 断点续传 (可选)
适用于网络不稳定场景,支持刷新页面后继续:
```python
class StoryWorkflowState(Base):
__tablename__ = "story_workflow_states"
story_id: Mapped[str] = mapped_column(String(36), primary_key=True)
status: Mapped[str] = mapped_column(String(20)) # pending/story_done/image_done/audio_done/complete
story_content: Mapped[str | None] = mapped_column(Text)
image_url: Mapped[str | None] = mapped_column(String(500))
audio_url: Mapped[str | None] = mapped_column(String(500))
last_error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, onupdate=datetime.utcnow)
async def resume_workflow(story_id: str) -> StoryResult:
state = await get_workflow_state(story_id)
if state.status == "story_done":
# 从图片+音频生成继续
return await generate_image_and_audio(state)
elif state.status == "image_done":
# 只需要生成音频
return await generate_audio_only(state)
elif state.status == "audio_done":
# 只需要生成图片
return await generate_image_only(state)
else:
return StoryResult.from_state(state)
```
### 推荐实现顺序
| 优先级 | 方案 | 收益 | 复杂度 | 状态 |
|--------|------|------|--------|------|
| P0 | 并行执行 | 节省 27% 时间 | 低 | ✅ 已完成 |
| P0 | 部分成功 | 提升容错性 | 低 | ✅ 已完成 |
| P1 | SSE 流式返回 | 体验大幅提升 | 中 | 待开始 |
| P2 | 断点续传 | 极端场景保障 | 高 | 待开始 |
**P0 实现详情:**
- 新增 API: `POST /api/generate/full`
- 文件: `api/stories.py:113-189`
- 响应模型: `FullStoryResponse` (含 `errors` 字段标识失败组件)
---
## 待决策清单
> **使用说明**: 在每个决策的 `[ ]` 中填入你的选择(如 `[x]` 或 `[B]`),确认后删除未选中的选项。
---
### 决策 1: 适配器配置存储
**问题**: 适配器的配置信息API地址、模型名、超时等存在哪里
| 选项 | 方案 | 优点 | 缺点 |
|------|------|------|------|
| [ ] A | 全部存数据库 | 完全动态,运行时可改 | 需要管理界面,初始化复杂 |
| [ ] B | 代码定义 + DB配置 | 平衡,核心逻辑在代码,参数可调 | 新适配器仍需改代码 |
| [ ] C | 配置文件 (YAML/JSON) | 简单,版本控制友好 | 改配置需重启 |
**推荐**: B代码定义适配器类DB存储启用状态/优先级/API Key引用
---
### 决策 2: 密钥管理
**问题**: API Key 等敏感信息如何存储?
| 选项 | 方案 | 优点 | 缺点 |
|------|------|------|------|
| [ ] A | 环境变量 | 简单,当前方式 | 多供应商时env膨胀改key需重启 |
| [ ] B | 数据库加密存储 | 动态管理支持多key | 需要加密方案,安全风险 |
| [ ] C | 外部密钥服务 (Vault/AWS Secrets) | 企业级安全 | 复杂,增加依赖 |
**推荐**: A当前阶段后期可迁移到B
---
### 决策 3: 图像供应商优先级
**问题**: 接入多个图像供应商后,默认使用哪个?
| 选项 | 供应商 | 特点 | 预估成本 |
|------|--------|------|----------|
| [ ] 1 | Nanobanana | 新兴,据说效果好 | 待调研 |
| [ ] 2 | Flux (当前) | 稳定,已接入 | ~$0.03/张 |
| [ ] 3 | DALL-E 3 | OpenAI出品质量高 | ~$0.04/张 |
| [ ] 4 | Midjourney | 艺术风格强 | API受限 |
**推荐**: 先调研Nanobanana效果好则替换Flux
---
### 决策 4: 文本供应商优先级
**问题**: 故事生成使用哪个LLM
| 选项 | 供应商 | 特点 | 预估成本 |
|------|--------|------|----------|
| [ ] 1 | Gemini (当前) | 免费额度大,中文好 | 免费/低成本 |
| [ ] 2 | OpenAI GPT-4o | 质量稳定 | ~$0.01/1K tokens |
| [ ] 3 | Claude | 创意写作强 | ~$0.015/1K tokens |
| [ ] 4 | Qwen (通义千问) | 国内,中文优化 | 待调研 |
**推荐**: Gemini为主OpenAI备用
---
### 决策 5: TTS供应商优先级
**问题**: 语音合成使用哪个服务?
| 选项 | 供应商 | 特点 | 预估成本 |
|------|--------|------|----------|
| [ ] 1 | Minimax (当前) | 中文效果好,已接入 | ~$0.01/1K字符 |
| [ ] 2 | ElevenLabs | 英文最佳,多语言 | ~$0.03/1K字符 |
| [ ] 3 | Azure TTS | 稳定,多语言 | ~$0.016/1K字符 |
| [ ] 4 | Google TTS | 便宜 | ~$0.004/1K字符 |
**推荐**: Minimax为主中文场景
---
### 决策 6: Admin Dashboard 技术栈
**问题**: 供应商管理后台用什么技术?
| 选项 | 方案 | 优点 | 缺点 |
|------|------|------|------|
| [ ] A | 复用 Vue 前端 | 技术栈统一,复用组件 | 需要自己写UI |
| [ ] B | React Admin | 成熟的Admin框架 | 引入新技术栈 |
| [ ] C | 现成方案 (AdminJS/Retool) | 开发快 | 定制性差,可能收费 |
**推荐**: A在现有Vue项目中加 `/admin` 路由)
---
### 决策 7: Phase 2 功能优先级
**问题**: 体验增强阶段先做哪个功能?
| 选项 | 功能 | 用户价值 | 开发复杂度 |
|------|------|----------|------------|
| [ ] 1 | 故事编辑 | 高用户可修改AI内容 | 中 |
| [ ] 2 | 角色定制 | 高(孩子成为主角) | 低 |
| [ ] 3 | 故事分享 | 高(增长引擎) | 中 |
| [ ] 4 | 故事续写 | 中(延长使用时长) | 中 |
**推荐**: 2 → 1 → 3 → 4角色定制最快出效果
---
### 决策 8: 并行与容错实现顺序
**问题**: 并行执行、部分成功、SSE、断点续传先做哪些
| 选项 | 方案 | 说明 |
|------|------|------|
| [ ] A | P0先做 | 先实现并行+部分成功,快速见效 |
| [ ] B | P0+P1一起 | 并行+部分成功+SSE体验完整 |
| [ ] C | 只做SSE | 跳过简单方案,直接上流式 |
**推荐**: A先P0验证后再做SSE
---
## 确认后删除此区块
确认所有决策后,可以删除未选中的选项,保留最终方案作为实现依据。

View File

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

View File

@@ -0,0 +1,72 @@
# DreamWeaver 工程鲁棒性改进计划
## 概述
本计划旨在提升 DreamWeaver 项目的工程质量,包括测试覆盖、稳定性、可观测性等方面。
## 任务列表
### P0 - 关键问题修复
#### Task-1: 修复 Rate Limit 内存泄漏
- **文件**: `backend/app/api/stories.py`
- **问题**: `_request_log` 全局字典无清理机制,长期运行内存无限增长
- **方案**: 添加 TTL 自动清理机制,使用 `cachetools.TTLCache`
- **测试**: 验证过期条目自动清理
#### Task-2: 添加核心 API 测试
- **文件**: `backend/tests/` (新建)
- **范围**:
- `test_auth.py`: OAuth 流程、session 验证
- `test_stories.py`: 故事 CRUD、rate limit
- **目标**: 核心路径覆盖率 ≥80%
### P1 - 稳定性提升
#### Task-3: 添加 API 重试机制
- **文件**: `backend/app/services/gemini.py`, `minimax.py`, `drawing.py`
- **方案**: 使用 `tenacity` 库,指数退避重试
- **配置**: 最多 3 次重试,初始间隔 1s
#### Task-4: 添加结构化日志
- **文件**: `backend/app/core/logging.py` (新建), 各 service 文件
- **方案**: 使用 `structlog`JSON 格式输出
- **埋点**: API 调用、错误、性能指标
### P2 - 代码优化
#### Task-5: 重构 Provider Router
- **文件**: `backend/app/services/provider_router.py`
- **问题**: 三个函数重复代码
- **方案**: 抽象通用 failover 函数
#### Task-6: 配置外部化
- **文件**: `backend/app/core/config.py`, `backend/app/services/gemini.py`
- **问题**: 模型名硬编码
- **方案**: 移至环境变量配置
#### Task-7: 修复脆弱的 URL 解析
- **文件**: `backend/app/services/drawing.py`
- **问题**: 字符串切片解析 URL 不可靠
- **方案**: 使用正则表达式
## 依赖关系
```
Task-1 (独立)
Task-2 (独立,但需要 Task-1 完成后验证)
Task-3 (独立)
Task-4 (独立)
Task-5 (独立)
Task-6 (独立)
Task-7 (独立)
```
## 新增依赖
```toml
# pyproject.toml [project.dependencies]
cachetools>=5.0.0 # Task-1: TTL cache
tenacity>=8.0.0 # Task-3: 重试机制
structlog>=24.0.0 # Task-4: 结构化日志
# [project.optional-dependencies.dev]
pytest-cov>=4.0.0 # Task-2: 覆盖率报告
httpx[http2] # Task-2: 测试 mock

303
.claude/ui-refactor-plan.md Normal file
View File

@@ -0,0 +1,303 @@
DreamWeaver 前端 UI 重构任务列表
阶段一:基础设施(必须先完成)
TASK-001 [x]: 安装图标库
文件: frontend/package.json
操作: 安装 @heroicons/vue 图标库
命令: npm install @heroicons/vue
验收: 能在 Vue 组件中 import { SparklesIcon } from '@heroicons/vue/24/outline'
TASK-002 [x]: 扩展 Tailwind 配置
文件: frontend/tailwind.config.js
操作: 添加完整的设计系统配置
内容:
- 扩展 fontFamily 添加 sans: ['Noto Sans SC', ...]
- 扩展 borderRadius 添加 '2xl': '1rem', '3xl': '1.5rem'
- 扩展 boxShadow 添加 'glass': '0 8px 32px rgba(0,0,0,0.08)'
- 扩展 animation 添加 'float': 'float 3s ease-in-out infinite'
- 扩展 keyframes 添加 float 动画定义
验收: Tailwind 类 font-sans, rounded-3xl, shadow-glass, animate-float 可用
TASK-003 [x]: 精简全局样式
文件: frontend/src/style.css
操作:
1. 删除 .animate-float (移至 Tailwind)
2. 删除 .stars::before/after (移除 emoji 装饰)
3. 保留 .glass, .btn-magic, .input-magic, .card-hover, .gradient-text
4. 删除 --gradient-magic 变量(过于花哨)
验收: 文件行数减少约 30%,无 emoji 相关 CSS
---
阶段二:创建可复用组件
TASK-004 [x]: 创建 BaseButton 组件
文件: frontend/src/components/ui/BaseButton.vue
操作: 创建统一按钮组件
Props:
- variant: 'primary' | 'secondary' | 'danger' | 'ghost'
- size: 'sm' | 'md' | 'lg'
- loading: boolean
- disabled: boolean
- icon: Component (可选Heroicon 组件)
样式规范:
- primary: 使用 .btn-magic 渐变
- secondary: bg-white border border-gray-200
- danger: bg-red-500 text-white
- ghost: bg-transparent hover:bg-gray-100
验收: 导出组件,支持 slot 内容和所有 props
TASK-005 [x]: 创建 BaseCard 组件
文件: frontend/src/components/ui/BaseCard.vue
操作: 创建统一卡片组件
Props:
- hover: boolean (是否启用悬浮效果)
- padding: 'none' | 'sm' | 'md' | 'lg'
样式: 使用 .glass + rounded-2xl + 可选 .card-hover
验收: 导出组件,支持默认 slot
TASK-006 [x]: 创建 BaseInput 组件
文件: frontend/src/components/ui/BaseInput.vue
操作: 创建统一输入框组件
Props:
- modelValue: string
- type: 'text' | 'password' | 'email' | 'number'
- placeholder: string
- label: string (可选)
- error: string (可选)
- disabled: boolean
样式: 使用 .input-magic + 错误状态红色边框
验收: 支持 v-model显示 label 和 error
TASK-007 [x]: 创建 BaseSelect 组件
文件: frontend/src/components/ui/BaseSelect.vue
操作: 创建统一下拉选择组件
Props:
- modelValue: string | number
- options: Array<{ value: string | number, label: string }>
- label: string (可选)
- placeholder: string
- disabled: boolean
样式: 与 BaseInput 保持一致
验收: 支持 v-model正确渲染 options
TASK-008 [x]: 创建 BaseTextarea 组件
文件: frontend/src/components/ui/BaseTextarea.vue
操作: 创建统一文本域组件
Props:
- modelValue: string
- placeholder: string
- rows: number
- maxLength: number (可选,显示字数统计)
- label: string (可选)
样式: 使用 .input-magic右下角显示字数
验收: 支持 v-model字数统计正确
TASK-009 [x]: 创建 LoadingSpinner 组件
文件: frontend/src/components/ui/LoadingSpinner.vue
操作: 创建统一加载动画组件
Props:
- size: 'sm' | 'md' | 'lg'
- text: string (可选,加载提示文字)
样式: 紫色渐变圆环旋转动画,无 emoji
验收: 三种尺寸正确渲染
TASK-010 [x]: 创建 EmptyState 组件
文件: frontend/src/components/ui/EmptyState.vue
操作: 创建统一空状态组件
Props:
- icon: Component (Heroicon)
- title: string
- description: string
- actionText: string (可选)
- actionTo: string (可选,路由路径)
样式: 居中布局,图标使用 Heroicon 而非 emoji
验收: 点击按钮正确跳转
TASK-011 [x]: 创建 ConfirmModal 组件
文件: frontend/src/components/ui/ConfirmModal.vue
操作: 创建统一确认弹窗组件
Props:
- show: boolean
- title: string
- message: string
- confirmText: string
- cancelText: string
- variant: 'danger' | 'warning' | 'info'
Emits: confirm, cancel
样式: 使用 .glass 背景Transition 动画
验收: 显示/隐藏动画流畅,事件正确触发
TASK-012 [x]: 创建组件导出索引
文件: frontend/src/components/ui/index.ts
操作: 统一导出所有 UI 组件
内容:
export { default as BaseButton } from './BaseButton.vue'
export { default as BaseCard } from './BaseCard.vue'
// ... 其他组件
验收: 可以 import { BaseButton, BaseCard } from '@/components/ui'
---
阶段三:重构现有页面
TASK-013 [x]: 重构 NavBar 组件
文件: frontend/src/components/NavBar.vue
操作:
1. 将 emoji ✨🌟📚🛠️🚪 替换为 Heroicons (SparklesIcon, StarIcon, BookOpenIcon, Cog6ToothIcon, ArrowRightOnRectangleIcon)
2. 将 ?? 占位符替换为正确图标 (UserGroupIcon, GlobeAltIcon)
3. 使用 BaseButton 替换登录按钮
4. 移除 animate-float 和 animate-pulse 装饰动画
验收: 无 emoji图标统一为 Heroicons视觉更专业
TASK-014 [x]: 重构 Home.vue 页面
文件: frontend/src/views/Home.vue
操作:
1. 删除 Hero 区域的浮动 emoji 装饰 (🌙⭐✨🌟)
2. 将模式切换按钮的 emoji (✨📝) 替换为 Heroicons
3. 将教育主题按钮的 emoji 替换为 Heroicons 或移除
4. 使用 BaseButton 替换提交按钮
5. 使用 BaseTextarea 替换文本输入区
6. 使用 BaseSelect 替换档案/宇宙选择器
7. 将 Features 区域的 emoji (🎨🔊📚) 替换为 Heroicons
验收: 页面无 emoji使用统一组件视觉简洁专业
TASK-015 [x]: 重构 MyStories.vue 页面
文件: frontend/src/views/MyStories.vue
操作:
1. 使用 BaseButton 替换"创作新故事"按钮
2. 使用 LoadingSpinner 替换自定义加载动画
3. 使用 EmptyState 替换空状态区域(移除 📚✨🪄 emoji
4. 将错误状态的 😢 替换为 Heroicon ExclamationCircleIcon
5. 将统计区域的 📖 替换为 Heroicon
6. 使用 BaseCard 包装故事卡片
验收: 页面无 emoji组件统一
TASK-016 [x]: 重构 StoryDetail.vue 页面
文件: frontend/src/views/StoryDetail.vue
操作:
1. 使用 LoadingSpinner 替换加载动画
2. 将 🎨 替换为 Heroicon PhotoIcon
3. 将 🔊 替换为 Heroicon SpeakerWaveIcon
4. 将 ✨ 替换为 Heroicon SparklesIcon
5. 将 🗑️ 替换为 Heroicon TrashIcon
6. 将 ⚠️ 替换为 Heroicon ExclamationTriangleIcon
7. 使用 BaseButton 替换所有按钮
8. 使用 ConfirmModal 替换删除确认弹窗
验收: 页面无 emoji弹窗使用统一组件
TASK-017 [x]: 重构 AdminProviders.vue 页面
文件: frontend/src/views/AdminProviders.vue
操作:
1. 移除 <style scoped> 中的所有自定义样式
2. 登录表单使用 BaseCard + BaseInput + BaseButton
3. Provider 表单使用 BaseCard + BaseInput + BaseSelect + BaseButton
4. 表格使用 Tailwind 样式divide-y divide-gray-200hover:bg-gray-50
5. 操作按钮使用 BaseButton variant="ghost"
6. 整体布局使用 .glass 背景
7. 添加页面标题使用 .gradient-text
验收: 与主应用风格一致,无原生 HTML 样式
TASK-018 [x]: 重构 ChildProfiles.vue 页面
文件: frontend/src/views/ChildProfiles.vue
操作:
1. 检查并替换所有 emoji 为 Heroicons
2. 使用 BaseButton, BaseCard, BaseInput 等统一组件
3. 使用 EmptyState 处理空状态
4. 使用 LoadingSpinner 处理加载状态
验收: 页面无 emoji组件统一
TASK-019 [x]: 重构 ChildProfileDetail.vue 页面
文件: frontend/src/views/ChildProfileDetail.vue
操作: 同 TASK-018
验收: 页面无 emoji组件统一
TASK-020 [x]: 重构 Universes.vue 页面
文件: frontend/src/views/Universes.vue
操作: 同 TASK-018
验收: 页面无 emoji组件统一
TASK-021 [x]: 重构 UniverseDetail.vue 页面
文件: frontend/src/views/UniverseDetail.vue
操作: 同 TASK-018
验收: 页面无 emoji组件统一
---
阶段四:优化与收尾
TASK-022 [x]: 添加深色模式支持(可选)
文件: frontend/tailwind.config.js, frontend/src/style.css
操作:
1. 在 tailwind.config.js 添加 darkMode: 'class'
2. 为 .glass, .btn-magic 等添加 dark: 变体
3. 在 NavBar 添加主题切换按钮
验收: 点击切换按钮,整体配色切换
TASK-023 [x]: 添加 prefers-reduced-motion 支持
文件: frontend/src/style.css
操作: 为所有动画添加媒体查询
@media (prefers-reduced-motion: reduce) {
.animate-float, .card-hover, .btn-magic { animation: none; transition: none; }
}
验收: 系统设置"减少动态效果"时,动画停止
TASK-024 [x]: 性能优化 - 减少 backdrop-filter
文件: frontend/src/style.css
操作:
1. 将 .glass 的 backdrop-filter: blur(20px) 改为 blur(10px)
2. 移除嵌套 .glass 元素的 backdrop-filter
验收: 页面滚动更流畅,尤其在移动端
---
执行顺序建议
阶段一 (TASK-001 ~ 003) → 阶段二 (TASK-004 ~ 012) → 阶段三 (TASK-013 ~ 021) → 阶段四 (TASK-022 ~ 024)
每个任务完成后运行 npm run build 确保无类型错误。

40
.gitignore vendored Normal file
View File

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

134
CLAUDE.md Normal file
View File

@@ -0,0 +1,134 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.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) |

90
README.md Normal file
View File

@@ -0,0 +1,90 @@
# DreamWeaver
AI 驱动的儿童故事生成应用,面向 3-8 岁儿童提供个性化童话创作。
## 技术栈
- 后端FastAPI、SQLAlchemy (async)、PostgreSQLOAuth (GitHub/Google)
- AI文本生成、图像生成、语音合成可替换适配器
- 前端Vue 3、TypeScript、Pinia、Vue Router、Vite
## 仓库结构
```
backend/
app/ # 后端代码
alembic/ # 数据库迁移
pyproject.toml
.env.example
frontend/
src/ # 前端代码
package.json
```
## 快速开始
### 后端
```bash
cd backend
python -m venv .venv
.\.venv\Scripts\activate # Linux/Mac: source .venv/bin/activate
pip install -e .
cp .env.example .env # 填写 SECRET_KEY、DATABASE_URL、各 API Key
# 运行数据库迁移(先配置好 DATABASE_URL
alembic upgrade head
# 启动
uvicorn app.main:app --reload --port 8000
```
### 前端
```bash
cd frontend
npm install
npm run dev
```
前端常用环境变量(可放 `.env.development`
- `VITE_API_BASE`:后端地址,例如 `http://localhost:8000`
- `VITE_ADMIN_USER` / `VITE_ADMIN_PASS`:管理后台 Basic Auth 账号(仅后台开启时需要)
### 访问
- 前端http://localhost:5173
- 后端 APIhttp://localhost:8000
- Swagger 文档http://localhost:8000/docs
## 供应商路由与管理后台
- 路由按配置顺序尝试:`TEXT_PROVIDERS`(默认 `text_primary`)、`IMAGE_PROVIDERS`(默认 `image_primary`)、`TTS_PROVIDERS`(默认 `tts_primary`)。失败会自动切换下一个。
- 管理后台(默认关闭):`ENABLE_ADMIN_CONSOLE=true` 时启用,接口在 `/admin/providers`CRUD`/admin/providers/reload`。鉴权使用 Basic Auth账号密码由 `ADMIN_USERNAME`/`ADMIN_PASSWORD` 设置(请覆盖默认值)。
- 建议后台放在受保护子域并在反代层加 Basic Auth/IP 白名单;生产环境默认关闭。
- 前端最小管理页:`/admin/providers`,仅后台开启时可用,使用上面的 Basic Auth 调用。
## 数据库迁移Alembic
- 运行迁移:`alembic upgrade head`
- 生成新迁移:`alembic revision -m "message" --autogenerate`
迁移脚本位于 `backend/alembic/versions/`,包含 `providers` 表和 `stories.mode` 字段。
## 主要 API
| 方法 | 路径 | 说明 |
| ---- | ---- | ---- |
| GET | `/auth/github/signin` | GitHub 登录 |
| GET | `/auth/google/signin` | Google 登录 |
| GET | `/auth/session` | 当前会话 |
| POST | `/api/generate` | 生成/润色故事 |
| POST | `/api/image/generate/{id}` | 生成封面图 |
| GET | `/api/audio/{id}` | 获取 TTS 音频 |
| GET | `/api/stories` | 获取故事列表(分页) |
| GET | `/api/stories/{id}` | 获取故事详情 |
| DELETE | `/api/stories/{id}` | 删除故事 |
| GET | `/admin/providers` | Provider 列表(需后台开启 + 管理员) |
## 环境变量
常用项(详见 `backend/.env.example`
- `SECRET_KEY`必填JWT 签名密钥
- `DATABASE_URL`必填PostgreSQL 连接串
- OAuth`GITHUB_CLIENT_ID/SECRET``GOOGLE_CLIENT_ID/SECRET`
- AI Keys`TEXT_API_KEY``TTS_API_BASE``TTS_API_KEY``IMAGE_API_KEY`
- Provider 列表:`TEXT_PROVIDERS``IMAGE_PROVIDERS``TTS_PROVIDERS`
- 管理后台:`ENABLE_ADMIN_CONSOLE``ADMIN_USERNAME``ADMIN_PASSWORD`
## 注意
- 生产务必使用强随机 `SECRET_KEY`,并关闭 `ENABLE_ADMIN_CONSOLE`(或放在受保护子域/内网)。
- 路由会按配置/DB 顺序尝试供应商并失败切换,请确保各 Key 有效且配额充足。

17
admin-frontend/.gitignore vendored Normal file
View File

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

23
admin-frontend/Dockerfile Normal file
View File

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

13
admin-frontend/index.html Normal file
View File

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

37
admin-frontend/nginx.conf Normal file
View File

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

2627
admin-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 113 B

View File

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

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from './stores/user'
import NavBar from './components/NavBar.vue'
const userStore = useUserStore()
const route = useRoute()
// 首页使用独立布局,不显示全局导航栏和 footer
const isHomePage = computed(() => route.path === '/')
onMounted(() => {
userStore.fetchSession()
})
</script>
<template>
<!-- 首页独立布局 -->
<template v-if="isHomePage">
<router-view />
</template>
<!-- 其他页面标准布局 -->
<template v-else>
<div class="min-h-screen bg-pattern">
<NavBar />
<main class="container mx-auto px-4 py-8 pb-20">
<router-view v-slot="{ Component }">
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-4"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-4"
>
<component :is="Component" />
</Transition>
</router-view>
</main>
<footer class="fixed bottom-0 left-0 right-0 py-4 text-center text-gray-400 text-sm glass border-t border-white/20">
<p>
<span class="gradient-text font-medium">梦语织机</span>
<span class="mx-2">·</span>
<span>为孩子编织专属的童话梦境</span>
</p>
</footer>
</div>
</template>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLocale } from '../i18n'
import {
ArrowRightOnRectangleIcon,
MoonIcon,
SparklesIcon,
StarIcon,
SunIcon,
} from '@heroicons/vue/24/outline'
const { locale } = useI18n()
const isDark = ref(false)
// 管理员状态直接读取 Storage
const isLoggedIn = computed(() => !!sessionStorage.getItem('admin_auth'))
function logout() {
sessionStorage.removeItem('admin_auth')
window.location.reload()
}
function switchLocale(lang: 'en' | 'zh') {
setLocale(lang)
}
function applyTheme(value: boolean) {
document.documentElement.classList.toggle('dark', value)
}
function toggleTheme() {
isDark.value = !isDark.value
applyTheme(isDark.value)
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
} else if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
isDark.value = true
}
applyTheme(isDark.value)
})
</script>
<template>
<nav class="glass sticky top-0 z-50 border-b border-white/20">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-18 py-3">
<router-link to="/" class="flex items-center space-x-3 group">
<div class="relative">
<SparklesIcon class="h-8 w-8 text-purple-500" />
<StarIcon class="absolute -top-1 -right-1 h-3.5 w-3.5 text-pink-400" />
</div>
<div>
<span class="text-2xl font-bold gradient-text">
DreamWeaver
</span>
<span class="inline-block px-2 py-0.5 ml-2 text-xs font-bold text-white bg-purple-600 rounded-full">
Admin
</span>
</div>
</router-link>
<div class="flex items-center space-x-3">
<button
class="px-3 py-2 text-sm rounded-lg hover:bg-white/50 transition"
:class="{ 'bg-white/70': locale === 'en' }"
@click="switchLocale('en')"
>
EN
</button>
<button
class="px-3 py-2 text-sm rounded-lg hover:bg-white/50 transition"
:class="{ 'bg-white/70': locale === 'zh' }"
@click="switchLocale('zh')"
>
中文
</button>
<button
class="p-2 rounded-lg hover:bg-white/50 transition"
@click="toggleTheme"
:aria-pressed="isDark"
>
<SunIcon v-if="isDark" class="h-5 w-5 text-amber-500" />
<MoonIcon v-else class="h-5 w-5 text-gray-500" />
</button>
<!-- 管理员已登录状态 -->
<div v-if="isLoggedIn" class="relative ml-4 pl-4 border-l border-gray-200">
<div class="flex items-center space-x-3">
<div class="text-right hidden sm:block">
<div class="text-sm font-bold text-gray-800">Administrator</div>
<div class="text-xs text-gray-500">Super User</div>
</div>
<button
@click="logout"
class="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="退出登录"
>
<ArrowRightOnRectangleIcon class="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</div>
</nav>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import type { Component } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
disabled?: boolean
icon?: Component
as?: string | Record<string, unknown>
}>(),
{
variant: 'primary',
size: 'md',
loading: false,
disabled: false,
as: 'button',
},
)
const attrs = useAttrs()
const isButton = computed(() => props.as === 'button' || !props.as)
const isDisabled = computed(() => props.disabled || props.loading)
const sizeClasses = computed(() => {
if (props.size === 'sm') return 'px-3 py-2 text-sm rounded-lg'
if (props.size === 'lg') return 'px-6 py-3 text-base rounded-xl'
return 'px-4 py-2.5 text-sm rounded-xl'
})
const variantClasses = computed(() => {
switch (props.variant) {
case 'secondary':
return 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50'
case 'danger':
return 'bg-red-500 text-white hover:bg-red-600'
case 'ghost':
return 'bg-transparent text-gray-600 hover:bg-gray-100'
default:
return 'btn-magic text-white'
}
})
const baseClasses = computed(() => [
'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-300',
sizeClasses.value,
variantClasses.value,
isDisabled.value ? 'opacity-60 cursor-not-allowed' : '',
])
const passthroughAttrs = computed(() => {
const { class: _class, type: _type, ...rest } = attrs
return rest
})
function handleClick(event: MouseEvent) {
if (!isButton.value && isDisabled.value) {
event.preventDefault()
event.stopPropagation()
}
}
</script>
<template>
<component
:is="props.as || 'button'"
:type="isButton ? (attrs.type as string || 'button') : undefined"
:disabled="isButton ? isDisabled : undefined"
:aria-disabled="!isButton && isDisabled ? 'true' : undefined"
:class="[baseClasses, attrs.class]"
v-bind="passthroughAttrs"
@click="handleClick"
>
<span
v-if="props.loading"
class="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
></span>
<component v-else-if="props.icon" :is="props.icon" class="h-5 w-5" aria-hidden="true" />
<slot />
</component>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
hover?: boolean
padding?: 'none' | 'sm' | 'md' | 'lg'
}>(),
{
hover: false,
padding: 'md',
},
)
const attrs = useAttrs()
const paddingClasses = computed(() => {
switch (props.padding) {
case 'none':
return 'p-0'
case 'sm':
return 'p-4'
case 'lg':
return 'p-8'
default:
return 'p-6'
}
})
const baseClasses = computed(() => [
'glass rounded-2xl',
paddingClasses.value,
props.hover ? 'card-hover' : '',
])
</script>
<template>
<div :class="[baseClasses, attrs.class]" v-bind="attrs">
<slot />
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: string | number | null
type?: 'text' | 'password' | 'email' | 'number' | 'date'
placeholder?: string
label?: string
error?: string
disabled?: boolean
modelModifiers?: { number?: boolean; trim?: boolean }
}>(),
{
type: 'text',
placeholder: '',
label: '',
error: '',
disabled: false,
},
)
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
const attrs = useAttrs()
const uid = `input-${Math.random().toString(36).slice(2, 9)}`
const inputId = computed(() => (attrs.id as string) || uid)
const inputClasses = computed(() => [
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 placeholder-gray-400 focus:outline-none',
props.error ? 'ring-2 ring-red-200' : '',
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
])
const passthroughAttrs = computed(() => {
const { class: _class, id: _id, ...rest } = attrs
return rest
})
</script>
<template>
<div class="space-y-2">
<label v-if="props.label" :for="inputId" class="text-sm font-medium text-gray-700">
{{ props.label }}
</label>
<input
:id="inputId"
:type="props.type"
:placeholder="props.placeholder"
:value="props.modelValue ?? ''"
:disabled="props.disabled"
:class="[inputClasses, attrs.class]"
v-bind="passthroughAttrs"
@input="(event) => {
let value = (event.target as HTMLInputElement).value
if (props.modelModifiers?.trim) value = value.trim()
if (props.modelModifiers?.number) {
const nextValue = Number(value)
emit('update:modelValue', Number.isNaN(nextValue) ? value : nextValue)
return
}
emit('update:modelValue', value)
}"
/>
<p v-if="props.error" class="text-sm text-red-500">
{{ props.error }}
</p>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
type Option = { value: string | number; label: string }
const props = withDefaults(
defineProps<{
modelValue?: string | number | null
options: Option[]
label?: string
placeholder?: string
disabled?: boolean
}>(),
{
label: '',
placeholder: '',
disabled: false,
},
)
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
const attrs = useAttrs()
const uid = `select-${Math.random().toString(36).slice(2, 9)}`
const selectId = computed(() => (attrs.id as string) || uid)
const selectClasses = computed(() => [
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 focus:outline-none',
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
])
const passthroughAttrs = computed(() => {
const { class: _class, id: _id, ...rest } = attrs
return rest
})
function handleChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
const matched = props.options.find(option => String(option.value) === value)
emit('update:modelValue', matched ? matched.value : value)
}
</script>
<template>
<div class="space-y-2">
<label v-if="props.label" :for="selectId" class="text-sm font-medium text-gray-700">
{{ props.label }}
</label>
<select
:id="selectId"
:value="props.modelValue ?? ''"
:disabled="props.disabled"
:class="[selectClasses, attrs.class]"
v-bind="passthroughAttrs"
@change="handleChange"
>
<option v-if="props.placeholder" value="">
{{ props.placeholder }}
</option>
<option v-for="option in props.options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue: string
placeholder?: string
rows?: number
maxLength?: number
label?: string
disabled?: boolean
}>(),
{
placeholder: '',
rows: 4,
label: '',
disabled: false,
},
)
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const attrs = useAttrs()
const uid = `textarea-${Math.random().toString(36).slice(2, 9)}`
const textareaId = computed(() => (attrs.id as string) || uid)
const textareaClasses = computed(() => [
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 placeholder-gray-400 focus:outline-none resize-none',
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
])
const passthroughAttrs = computed(() => {
const { class: _class, id: _id, ...rest } = attrs
return rest
})
</script>
<template>
<div class="space-y-2">
<label v-if="props.label" :for="textareaId" class="text-sm font-medium text-gray-700">
{{ props.label }}
</label>
<div class="relative">
<textarea
:id="textareaId"
:rows="props.rows"
:placeholder="props.placeholder"
:value="props.modelValue"
:maxlength="props.maxLength"
:disabled="props.disabled"
:class="[textareaClasses, attrs.class]"
v-bind="passthroughAttrs"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
></textarea>
<div v-if="props.maxLength" class="absolute bottom-3 right-4 text-xs text-gray-400">
{{ props.modelValue.length }} / {{ props.maxLength }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from './BaseButton.vue'
const props = withDefaults(
defineProps<{
show: boolean
title: string
message: string
confirmText?: string
cancelText?: string
variant?: 'danger' | 'warning' | 'info'
}>(),
{
confirmText: '确认',
cancelText: '取消',
variant: 'info',
},
)
const emit = defineEmits<{ confirm: []; cancel: [] }>()
const confirmVariant = computed(() => (props.variant === 'danger' ? 'danger' : 'primary'))
const headerClasses = computed(() => {
if (props.variant === 'danger') return 'text-red-600'
if (props.variant === 'warning') return 'text-amber-600'
return 'text-gray-800'
})
</script>
<template>
<Transition
enter-active-class="transition-all duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-all duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="props.show" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="emit('cancel')"></div>
<div class="relative glass rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h3 class="text-lg font-semibold" :class="headerClasses">
{{ props.title }}
</h3>
<p class="mt-2 text-sm text-gray-600">
{{ props.message }}
</p>
<div class="mt-6 flex justify-end gap-3">
<BaseButton variant="secondary" @click="emit('cancel')">
{{ props.cancelText }}
</BaseButton>
<BaseButton :variant="confirmVariant" @click="emit('confirm')">
{{ props.confirmText }}
</BaseButton>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { Component } from 'vue'
import BaseButton from './BaseButton.vue'
const props = withDefaults(
defineProps<{
icon: Component
title: string
description: string
actionText?: string
actionTo?: string
}>(),
{
actionText: '',
actionTo: '',
},
)
const emit = defineEmits<{ action: [] }>()
function handleAction() {
if (!props.actionTo) emit('action')
}
</script>
<template>
<div class="flex flex-col items-center justify-center text-center py-12">
<component :is="props.icon" class="h-14 w-14 text-purple-400" aria-hidden="true" />
<h3 class="mt-4 text-xl font-semibold text-gray-800">
{{ props.title }}
</h3>
<p class="mt-2 text-sm text-gray-500">
{{ props.description }}
</p>
<BaseButton
v-if="props.actionText"
:as="props.actionTo ? 'router-link' : 'button'"
:to="props.actionTo || undefined"
class="mt-6"
@click="handleAction"
>
{{ props.actionText }}
</BaseButton>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
size?: 'sm' | 'md' | 'lg'
text?: string
}>(),
{
size: 'md',
text: '',
},
)
const sizeClasses = computed(() => {
switch (props.size) {
case 'sm':
return 'h-6 w-6 border-2'
case 'lg':
return 'h-14 w-14 border-4'
default:
return 'h-10 w-10 border-4'
}
})
</script>
<template>
<div class="flex flex-col items-center justify-center">
<div
:class="['animate-spin rounded-full border-purple-200 border-t-purple-500 border-r-pink-400', sizeClasses]"
></div>
<p v-if="props.text" class="mt-4 text-sm text-gray-500">
{{ props.text }}
</p>
</div>
</template>

View File

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

View File

@@ -0,0 +1,8 @@
export { default as BaseButton } from './BaseButton.vue'
export { default as BaseCard } from './BaseCard.vue'
export { default as BaseInput } from './BaseInput.vue'
export { default as BaseSelect } from './BaseSelect.vue'
export { default as BaseTextarea } from './BaseTextarea.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'

View File

@@ -0,0 +1,27 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import zh from './locales/zh.json'
const messages = { en, zh }
function detectLocale(): 'en' | 'zh' {
const saved = localStorage.getItem('locale')
if (saved === 'en' || saved === 'zh') return saved
const lang = navigator.language.toLowerCase()
if (lang.startsWith('zh')) return 'zh'
return 'en'
}
const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'en',
messages,
})
export function setLocale(locale: 'en' | 'zh') {
localStorage.setItem('locale', locale)
i18n.global.locale.value = locale
}
export default i18n

View File

@@ -0,0 +1,154 @@
{
"app": {
"title": "DreamWeaver",
"navHome": "Home",
"navMyStories": "My Stories",
"navProfiles": "Profiles",
"navUniverses": "Universes",
"navAdmin": "Providers Admin"
},
"home": {
"heroTitle": "Weave magical",
"heroTitleHighlight": "bedtime stories for your child",
"heroSubtitle": "AI-powered personalized stories for children aged 3-8, making every bedtime magical",
"heroCta": "Start Creating",
"heroCtaSecondary": "Learn More",
"heroPreviewTitle": "Bunny's Brave Adventure",
"heroPreviewText": "In a forest kissed by morning dew, there lived a little white bunny named Cotton...",
"trustStoriesCreated": "Stories Created",
"trustFamilies": "Families Trust Us",
"trustSatisfaction": "Satisfaction",
"featuresTitle": "Why Choose DreamWeaver",
"featuresSubtitle": "We combine AI technology with educational principles to create unique stories for every child",
"feature1Title": "AI-Powered Creation",
"feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child",
"feature2Title": "Personalized Memory",
"feature2Desc": "The system remembers your child's preferences and growth, making stories more tailored over time",
"feature3Title": "Beautiful AI Illustrations",
"feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life",
"feature4Title": "Warm Voice Narration",
"feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams",
"feature5Title": "Educational Themes",
"feature5Desc": "Courage, friendship, sharing, honesty... naturally weaving positive values into stories",
"feature6Title": "Story Universe",
"feature6Desc": "Create your own world where beloved characters continue their adventures across stories",
"howItWorksTitle": "How It Works",
"howItWorksSubtitle": "Four steps to start your magical story journey",
"step1Title": "Enter Ideas",
"step1Desc": "Input keywords, characters, or simple ideas",
"step2Title": "AI Creates",
"step2Desc": "AI generates a unique story based on your input",
"step3Title": "Enrich Content",
"step3Desc": "Auto-generate beautiful illustrations and audio",
"step4Title": "Share Stories",
"step4Desc": "Save and tell stories to your child anytime",
"showcaseTitle": "Designed for Parents",
"showcaseSubtitle": "Simple to use, powerful features",
"showcaseFeature1": "Intuitive interface, generate stories in seconds",
"showcaseFeature2": "Multi-child profile management with separate memories",
"showcaseFeature3": "Story history saved forever, revisit precious moments",
"showcaseFeature4": "Bilingual support to nurture language skills",
"testimonialsTitle": "What Parents Say",
"testimonialsSubtitle": "Real feedback from our users",
"testimonial1Text": "Every night before bed, my daughter wants a new story. DreamWeaver saves me from making up stories, and the quality is amazing!",
"testimonial1Name": "Sarah M.",
"testimonial1Role": "Parent of 5-year-old girl",
"testimonial2Text": "The personalization is incredible! It remembers my son loves dinosaurs and space, and every story hits his interests perfectly.",
"testimonial2Name": "Michael T.",
"testimonial2Role": "Parent of 6-year-old boy",
"testimonial3Text": "The voice narration is fantastic! Even when traveling, I can tell stories remotely. The voice is warm and natural, my daughter loves it.",
"testimonial3Name": "Jennifer L.",
"testimonial3Role": "Parent of 4-year-old girl",
"faqTitle": "Frequently Asked Questions",
"faq1Question": "What age is DreamWeaver suitable for?",
"faq1Answer": "We're designed for children aged 3-8. Story content, language difficulty, and educational themes are all optimized for this age group.",
"faq2Question": "Are the generated stories safe?",
"faq2Answer": "Absolutely safe. All stories go through content filtering to ensure they're appropriate for children and convey positive values.",
"faq3Question": "Can I customize story characters?",
"faq3Answer": "Yes! You can set preferences in your child's profile, or specify character names and traits when creating. AI will incorporate them into the story.",
"faq4Question": "Will stories repeat?",
"faq4Answer": "No. Every story is originally generated by AI in real-time. Even with the same keywords, you'll get different stories each time.",
"faq5Question": "What languages are supported?",
"faq5Answer": "Currently we support Chinese and English. You can switch interface language anytime, and stories will adjust accordingly.",
"ctaTitle": "Ready to Create Magic for Your Child?",
"ctaSubtitle": "Start now and let AI weave unique stories for your child's growth",
"ctaButton": "Start Creating Free",
"ctaNote": "No credit card required",
"createModalTitle": "Create New Story",
"inputTypeKeywords": "Keywords",
"inputTypeStory": "Polish Story",
"selectProfile": "Select Child Profile",
"selectProfileOptional": "(Optional)",
"selectUniverse": "Select Story Universe",
"noProfile": "No profile",
"noUniverse": "No universe",
"noUniverseHint": "No universe for this profile yet. Create one in Story Universe.",
"inputLabel": "Enter Keywords",
"inputLabelStory": "Enter Your Story",
"inputPlaceholder": "e.g., bunny, forest, courage, friendship...",
"inputPlaceholderStory": "Enter the story you want to polish...",
"themeLabel": "Select Educational Theme",
"themeOptional": "(Optional)",
"themeCourage": "Courage",
"themeFriendship": "Friendship",
"themeSharing": "Sharing",
"themeHonesty": "Honesty",
"themePersistence": "Persistence",
"themeTolerance": "Tolerance",
"themeCustom": "Or custom...",
"errorEmpty": "Please enter content",
"errorLogin": "Please login first",
"generating": "Weaving your story...",
"loginFirst": "Please Login",
"startCreate": "Create Magic Story"
},
"stories": {
"myStories": "My Stories",
"view": "View",
"delete": "Delete",
"confirmDelete": "Are you sure to delete this story?",
"noStories": "No stories yet."
},
"storyDetail": {
"back": "Back",
"generateImage": "Generate Cover",
"playAudio": "Play Audio",
"modeGenerated": "Generated",
"modeEnhanced": "Enhanced"
},
"admin": {
"title": "Provider Management",
"reload": "Reload Cache",
"create": "Create",
"edit": "Edit",
"save": "Save",
"clear": "Clear",
"delete": "Delete",
"name": "Name",
"type": "Type",
"adapter": "Adapter",
"model": "Model",
"apiBase": "API Base",
"timeout": "Timeout (ms)",
"retries": "Max Retries",
"weight": "Weight",
"priority": "Priority",
"configRef": "Config Ref",
"enabled": "Enabled",
"actions": "Actions"
},
"common": {
"enabled": "Enabled",
"disabled": "Disabled",
"confirm": "Confirm",
"cancel": "Cancel"
}
}

View File

@@ -0,0 +1,154 @@
{
"app": {
"title": "梦语织机",
"navHome": "首页",
"navMyStories": "我的故事",
"navProfiles": "孩子档案",
"navUniverses": "故事宇宙",
"navAdmin": "供应商管理"
},
"home": {
"heroTitle": "为孩子编织",
"heroTitleHighlight": "专属的童话梦境",
"heroSubtitle": "AI 智能创作个性化成长故事,陪伴 3-8 岁孩子的每一个美好夜晚",
"heroCta": "开始创作",
"heroCtaSecondary": "了解更多",
"heroPreviewTitle": "小兔子的勇气冒险",
"heroPreviewText": "在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔...",
"trustStoriesCreated": "故事已创作",
"trustFamilies": "家庭信赖",
"trustSatisfaction": "满意度",
"featuresTitle": "为什么选择梦语织机",
"featuresSubtitle": "我们用 AI 技术和教育理念,为每个孩子打造独一无二的成长故事",
"feature1Title": "AI 智能创作",
"feature1Desc": "输入几个关键词AI 即刻为您的孩子创作一个充满想象力的原创故事",
"feature2Title": "个性化记忆",
"feature2Desc": "系统记住孩子的喜好和成长轨迹,故事越来越懂 TA",
"feature3Title": "精美 AI 插画",
"feature3Desc": "为每个故事自动生成独特的精美封面插画,让故事更加生动",
"feature4Title": "温暖语音朗读",
"feature4Desc": "专业级 AI 配音,温暖的声音陪伴孩子进入甜美梦乡",
"feature5Title": "教育主题融入",
"feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观",
"feature6Title": "故事宇宙",
"feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险",
"howItWorksTitle": "如何使用",
"howItWorksSubtitle": "四步开启奇妙故事之旅",
"step1Title": "输入灵感",
"step1Desc": "输入关键词、角色或简单想法",
"step2Title": "AI 创作",
"step2Desc": "AI 根据输入生成专属故事",
"step3Title": "丰富内容",
"step3Desc": "自动生成精美插画和语音",
"step4Title": "分享故事",
"step4Desc": "保存收藏,随时为孩子讲述",
"showcaseTitle": "专为家长设计",
"showcaseSubtitle": "简单易用,功能强大",
"showcaseFeature1": "直观的创作界面,几秒即可生成故事",
"showcaseFeature2": "多孩子档案管理,每个孩子独立记忆",
"showcaseFeature3": "故事历史永久保存,随时回顾美好时光",
"showcaseFeature4": "支持中英双语,培养语言能力",
"testimonialsTitle": "家长们怎么说",
"testimonialsSubtitle": "来自真实用户的反馈",
"testimonial1Text": "每晚睡前,女儿都要听一个新故事。梦语织机让我不再为编故事发愁,而且故事质量真的很高!",
"testimonial1Name": "小雨妈妈",
"testimonial1Role": "5岁女孩家长",
"testimonial2Text": "最惊喜的是个性化功能,系统记住了儿子喜欢恐龙和太空,每个故事都能戳中他的兴趣点。",
"testimonial2Name": "航航爸爸",
"testimonial2Role": "6岁男孩家长",
"testimonial3Text": "语音朗读功能太棒了!出差时也能远程给孩子讲故事,声音温暖自然,孩子很喜欢。",
"testimonial3Name": "朵朵妈妈",
"testimonial3Role": "4岁女孩家长",
"faqTitle": "常见问题",
"faq1Question": "梦语织机适合多大的孩子?",
"faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。",
"faq2Question": "生成的故事安全吗?",
"faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。",
"faq3Question": "可以自定义故事角色吗?",
"faq3Answer": "可以您可以在孩子档案中设置喜好或在创作时指定角色名称、特点AI 会将其融入故事。",
"faq4Question": "故事会重复吗?",
"faq4Answer": "不会。每个故事都是 AI 实时原创生成的,即使使用相同关键词,也会产生不同的故事。",
"faq5Question": "支持哪些语言?",
"faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。",
"ctaTitle": "准备好为孩子创造魔法了吗?",
"ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事",
"ctaButton": "免费开始创作",
"ctaNote": "无需信用卡,立即体验",
"createModalTitle": "创作新故事",
"inputTypeKeywords": "关键词创作",
"inputTypeStory": "故事润色",
"selectProfile": "选择孩子档案",
"selectProfileOptional": "(可选)",
"selectUniverse": "选择故事宇宙",
"noProfile": "不使用档案",
"noUniverse": "不选择宇宙",
"noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建",
"inputLabel": "输入关键词",
"inputLabelStory": "输入您的故事",
"inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...",
"inputPlaceholderStory": "在这里输入您想要润色的故事...",
"themeLabel": "选择教育主题",
"themeOptional": "(可选)",
"themeCourage": "勇气",
"themeFriendship": "友谊",
"themeSharing": "分享",
"themeHonesty": "诚实",
"themePersistence": "坚持",
"themeTolerance": "包容",
"themeCustom": "或自定义...",
"errorEmpty": "请输入内容",
"errorLogin": "请先登录",
"generating": "正在编织故事...",
"loginFirst": "请先登录",
"startCreate": "开始创作魔法故事"
},
"stories": {
"myStories": "我的故事",
"view": "查看",
"delete": "删除",
"confirmDelete": "确定删除这个故事吗?",
"noStories": "暂无故事。"
},
"storyDetail": {
"back": "返回",
"generateImage": "生成封面",
"playAudio": "播放音频",
"modeGenerated": "生成",
"modeEnhanced": "润色"
},
"admin": {
"title": "供应商管理",
"reload": "重载缓存",
"create": "创建",
"edit": "编辑",
"save": "保存",
"clear": "清空",
"delete": "删除",
"name": "名称",
"type": "类型",
"adapter": "适配器",
"model": "模型",
"apiBase": "API Base",
"timeout": "超时 (ms)",
"retries": "最大重试",
"weight": "权重",
"priority": "优先级",
"configRef": "Config Ref",
"enabled": "启用",
"actions": "操作"
},
"common": {
"enabled": "启用",
"disabled": "停用",
"confirm": "确认",
"cancel": "取消"
}
}

View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import i18n from './i18n'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.mount('#app')

View File

@@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/console/providers',
},
{
path: '/my-stories',
name: 'my-stories',
component: () => import('./views/MyStories.vue'),
},
{
path: '/profiles',
name: 'profiles',
component: () => import('./views/ChildProfiles.vue'),
},
{
path: '/profiles/:id',
name: 'profile-detail',
component: () => import('./views/ChildProfileDetail.vue'),
},
{
path: '/profiles/:id/timeline',
name: 'profile-timeline',
component: () => import('./views/ChildProfileTimeline.vue'),
},
{
path: '/universes',
name: 'universes',
component: () => import('./views/Universes.vue'),
},
{
path: '/universes/:id',
name: 'universe-detail',
component: () => import('./views/UniverseDetail.vue'),
},
{
path: '/story/:id',
name: 'story-detail',
component: () => import('./views/StoryDetail.vue'),
},
{
path: '/storybook/view',
name: 'storybook-viewer',
component: () => import('./views/StorybookViewer.vue'),
},
{
path: '/console/providers',
name: 'admin-providers',
component: () => import('./views/AdminProviders.vue'),
meta: { requiresAdmin: true },
},
],
})
export default router

View File

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

View File

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

View File

@@ -0,0 +1,243 @@
/* 引入霞鹜文楷 */
@import url('https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-web/style.css');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 全局基础样式 */
body {
/* 优先使用文楷,营造书卷气 */
font-family: 'LXGW WenKai Screen', 'Noto Sans SC', system-ui, sans-serif;
/* 米色纸张背景 */
background-color: #FDFBF7;
color: #292524; /* Stone-800 暖炭黑 */
/* 细微的纹理 (可选) */
background-image: radial-gradient(#E5E7EB 1px, transparent 1px);
background-size: 32px 32px;
}
/* 暗色模式适配 */
.dark body {
background-color: #1C1917; /* Stone-900 */
background-image: radial-gradient(#292524 1px, transparent 1px);
color: #E7E5E4; /* Stone-200 */
}
/* 自定义滚动条 - 更加柔和 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #D6D3D1; /* Stone-300 */
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #A8A29E; /* Stone-400 */
}
/* 卡片风格 - 实体书签感 */
.glass {
background-color: #FFFFFF;
border: 1px solid #E7E5E4; /* Stone-200 */
border-radius: 1rem; /* 16px */
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 4px -1px rgba(0, 0, 0, 0.02);
transition: all 0.3s ease;
}
.dark .glass {
background-color: #292524;
border-color: #44403C;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
/* 标题风格 - 不再使用渐变,而是强调字重和颜色 */
.gradient-text {
background: none;
-webkit-background-clip: unset;
-webkit-text-fill-color: initial;
color: #1F2937; /* Gray-800 */
font-weight: 800; /* ExtraBold */
letter-spacing: -0.025em;
}
.dark .gradient-text {
color: #F3F4F6;
}
/* 按钮 - 暖色调琥珀色 */
.btn-magic {
background-color: #F59E0B; /* Amber-500 */
color: #FFFFFF;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2);
}
.btn-magic:hover {
background-color: #D97706; /* Amber-600 */
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(245, 158, 11, 0.3);
}
.btn-magic:active {
transform: translateY(0);
}
/* 卡片悬浮 */
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025);
border-color: #F59E0B; /* 悬浮时边框变黄 */
}
/* 输入框 - 极简白卡纸风格 */
.input-magic {
background: #FFFFFF;
border: 1px solid #E7E5E4; /* Stone-200 */
color: #292524;
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.input-magic:focus {
outline: none;
border-color: #F59E0B; /* Amber-500 */
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
}
.dark .input-magic {
background: #292524;
border-color: #44403C;
color: #E7E5E4;
}
/* 装饰背景 - 移除复杂的动画光斑,保持干净 */
.bg-pattern {
background: none;
}
.dark body {
background: linear-gradient(135deg, #0f172a 0%, #111827 35%, #1f2937 70%, #111827 100%);
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #c084fc, #f472b6);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #a855f7, #ec4899);
}
/* 玻璃态效果 */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.dark .glass {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.25);
}
/* 渐变文字 */
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 按钮样式 */
.btn-magic {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 200% 200%;
transition: all 0.3s ease;
}
.btn-magic:hover {
background-position: 100% 100%;
transform: translateY(-2px);
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
}
.btn-magic:active {
transform: translateY(0);
}
.dark .btn-magic {
background: linear-gradient(135deg, #7c3aed 0%, #4c1d95 50%, #a855f7 100%);
}
/* 卡片悬浮效果 */
.card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-hover:hover {
transform: translateY(-8px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
}
/* 输入框聚焦效果 */
.input-magic {
transition: all 0.3s ease;
border: 2px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #e9d5ff, #fbcfe8, #bfdbfe) border-box;
}
.input-magic:focus {
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #a855f7, #ec4899, #3b82f6) border-box;
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.1);
}
.dark .input-magic {
background: linear-gradient(#0f172a, #0f172a) padding-box,
linear-gradient(135deg, #7c3aed, #db2777, #2563eb) border-box;
}
/* 装饰性背景 */
.bg-pattern {
background-image:
radial-gradient(circle at 20% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.05) 0%, transparent 50%);
}
.dark .bg-pattern {
background-image:
radial-gradient(circle at 20% 80%, rgba(124, 58, 237, 0.18) 0%, transparent 55%),
radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.18) 0%, transparent 55%),
radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.12) 0%, transparent 55%);
}
@media (prefers-reduced-motion: reduce) {
.animate-float, .card-hover, .btn-magic {
animation: none;
transition: none;
}
}

View File

@@ -0,0 +1,475 @@
<template>
<main class="max-w-7xl mx-auto px-4 py-8">
<BaseCard v-if="!isLoggedIn" class="max-w-md mx-auto mt-20" padding="lg">
<h1 class="text-3xl font-bold gradient-text mb-4 text-center">DreamWeaver 控制台</h1>
<p class="text-sm text-gray-500 mb-8 text-center">请登录以管理 AI 计算引擎与策略</p>
<form @submit.prevent="login" class="space-y-6">
<BaseInput v-model="loginForm.username" label="管理员账号" required placeholder="admin" />
<BaseInput v-model="loginForm.password" label="密钥密码" type="password" required />
<div class="flex items-center justify-between mt-6">
<span v-if="loginError" class="text-sm text-red-500 font-medium">{{ loginError }}</span>
<BaseButton type="submit" class="w-full">进入控制台</BaseButton>
</div>
</form>
</BaseCard>
<div v-else class="space-y-8">
<!-- Header -->
<header class="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div>
<h1 class="text-3xl font-bold gradient-text">引擎调度中心</h1>
<p class="text-sm text-gray-500 mt-1">Provider Orchestration & Strategy</p>
</div>
<div class="flex items-center gap-3">
<div class="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
系统运行中
</div>
<BaseButton variant="ghost" @click="logout" size="sm">退出</BaseButton>
</div>
</header>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Left: Status & Defaults -->
<div class="lg:col-span-1 space-y-6">
<BaseCard padding="md" title="出厂默认策略 (.env)">
<div class="space-y-4">
<div v-for="(providers, type) in defaults" :key="type" class="p-3 bg-gray-50 rounded-lg border border-gray-100">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ type }}</span>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="p in providers"
:key="p"
@click="cloneDefault(type, p)"
class="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 font-mono hover:border-indigo-300 hover:text-indigo-600 hover:shadow-sm transition-all cursor-pointer"
title="点击基于此默认配置创建"
>
{{ p }}
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-2 px-1">
* 当数据库中未配置或全部禁用时系统将回退到上述出厂设置
</p>
</div>
</BaseCard>
<BaseCard padding="md" title="可用驱动 (Adapters)">
<div class="flex flex-wrap gap-2">
<span v-for="adapter in availableAdapters" :key="adapter"
class="px-2 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-100">
{{ adapter.split(':')[1] }} <span class="opacity-50 text-[10px]">({{ adapter.split(':')[0] }})</span>
</span>
</div>
</BaseCard>
</div>
<!-- Right: Active Manager -->
<div class="lg:col-span-3 space-y-6">
<!-- Tabs -->
<div class="flex space-x-1 bg-gray-100 p-1 rounded-xl w-fit">
<button
v-for="tab in ['text', 'image', 'tts', 'storybook']"
:key="tab"
@click="activeTab = tab"
class="px-6 py-2 rounded-lg text-sm font-medium transition-all duration-200"
:class="activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
>
{{ tab.toUpperCase() }}
</button>
</div>
<!-- Provider Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Add New Card -->
<button @click="openCreateModal" class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-200 rounded-2xl hover:border-indigo-300 hover:bg-indigo-50 transition-all group min-h-[200px]">
<div class="w-12 h-12 rounded-full bg-white shadow-sm flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
<svg class="w-6 h-6 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
</div>
<span class="text-sm font-medium text-gray-600 group-hover:text-indigo-600">添加新的 {{ activeTab }} 引擎</span>
</button>
<!-- Existing Cards -->
<div v-for="p in filteredProviders" :key="p.id"
class="relative p-6 bg-white rounded-2xl border transition-all duration-200 group hover:shadow-lg"
:class="p.enabled ? 'border-gray-200' : 'border-gray-100 opacity-75 bg-gray-50'"
>
<!-- Enabled Toggle -->
<div class="absolute top-4 right-4 z-10">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-bold text-lg text-gray-900">{{ p.name }}</h3>
<div class="flex items-center gap-2 mt-1">
<span class="px-2 py-0.5 rounded text-xs font-mono bg-gray-100 text-gray-600 border border-gray-200">
{{ p.adapter }}
</span>
<span v-if="p.model" class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 border border-blue-100 truncate max-w-[120px]">
{{ p.model }}
</span>
</div>
</div>
</div>
<div class="space-y-2 text-sm text-gray-500 mb-6">
<div class="flex justify-between">
<span>Priority:</span>
<span class="font-medium text-gray-700">{{ p.priority }}</span>
</div>
<div class="flex justify-between">
<span>API Key:</span>
<span :class="p.has_api_key ? 'text-green-600' : 'text-yellow-600'">
{{ p.has_api_key ? '● Configured' : '○ Not Set (Env?)' }}
</span>
</div>
</div>
<div class="flex items-center gap-2 pt-4 border-t border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
<BaseButton size="sm" variant="secondary" class="flex-1" @click="edit(p)">配置</BaseButton>
<BaseButton size="sm" variant="ghost" class="text-red-500 hover:bg-red-50" @click="remove(p)">删除</BaseButton>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit/Create Modal -->
<div v-if="editing" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="reset">
<BaseCard class="w-full max-w-2xl max-h-[90vh] overflow-y-auto" padding="lg" @click.stop>
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold gradient-text">{{ form.id ? '编辑引擎配置' : '添加新引擎' }}</h2>
<button @click="reset" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<BaseInput v-model="form.name" label="名称 (显示名)" placeholder="如: Pro GPT-4" required />
<BaseSelect
v-model="form.adapter"
label="驱动程序 (Adapter)"
:options="adapterOptions"
required
description="选择底层的 API 驱动协议"
/>
<BaseInput v-model="form.model" label="模型名称 (Model)" placeholder="如: gpt-4o, minimax-v2" description="具体调用的模型ID" />
<BaseInput v-model.number="form.priority" label="优先级 (0-100)" type="number" description="数字越大越优先" />
<div class="md:col-span-2 p-4 bg-gray-50 rounded-xl border border-gray-100 space-y-4">
<h3 class="text-sm font-bold text-gray-700">密钥与连接</h3>
<BaseInput v-model="form.api_key" label="API Key" type="password" placeholder="留空则使用 .env 配置" :required="!form.id && !form.config_ref" />
<BaseInput v-model="form.api_base" label="API Endpoint / Group ID" placeholder="https://... 或 Group ID" />
<BaseInput v-model="form.config_ref" label="Fallback Env Var" placeholder="如: OPENAI_API_KEY (高级)" />
</div>
<!-- MiniMax Specific Config -->
<div v-if="form.adapter === 'minimax'" class="md:col-span-2 p-4 bg-indigo-50 rounded-xl border border-indigo-100 space-y-4">
<h3 class="text-sm font-bold text-indigo-700">MiniMax 语音参数</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseInput v-model="form.config_json.voice_id" label="音色 ID (Voice ID)" placeholder="male-qn-qingse" />
<BaseInput v-model="form.config_json.emotion" label="情感 (Emotion)" placeholder="happy / sad / angry" />
<div class="space-y-2">
<div class="flex justify-between">
<label class="text-xs font-semibold text-gray-600">语速 (Speed)</label>
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.speed || 1.0 }}x</span>
</div>
<input type="range" v-model.number="form.config_json.speed" min="0.5" max="2.0" step="0.1" class="w-full accent-indigo-600" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<label class="text-xs font-semibold text-gray-600">音量 (Volume)</label>
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.vol || 1.0 }}</span>
</div>
<input type="range" v-model.number="form.config_json.vol" min="0.1" max="5.0" step="0.1" class="w-full accent-indigo-600" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<label class="text-xs font-semibold text-gray-600">音高 (Pitch)</label>
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.pitch || 0 }}</span>
</div>
<input type="range" v-model.number="form.config_json.pitch" min="-12" max="12" step="1" class="w-full accent-indigo-600" />
</div>
</div>
</div>
<!-- CQTAI Specific Config -->
<div v-if="form.adapter === 'cqtai'" class="md:col-span-2 p-4 bg-purple-50 rounded-xl border border-purple-100 space-y-4">
<h3 class="text-sm font-bold text-purple-700">CQTAI 画图参数</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseSelect
v-model="form.config_json.aspect_ratio"
label="画面比例 (Aspect Ratio)"
:options="[
{ value: '1:1', label: '1:1 (正方形)' },
{ value: '16:9', label: '16:9 (横屏)' },
{ value: '9:16', label: '9:16 (竖屏)' },
{ value: '4:3', label: '4:3 (传统)' },
{ value: '3:4', label: '3:4 (海报)' }
]"
placeholder="默认 1:1"
/>
<BaseSelect
v-model="form.config_json.resolution"
label="分辨率 (Resolution)"
:options="[
{ value: '1K', label: '1K (标准)' },
{ value: '2K', label: '2K (高清)' },
{ value: '4K', label: '4K (超清)' }
]"
placeholder="默认 1K"
/>
</div>
</div>
<!-- Antigravity Specific Config -->
<div v-if="form.adapter === 'antigravity'" class="md:col-span-2 p-4 bg-emerald-50 rounded-xl border border-emerald-100 space-y-4">
<h3 class="text-sm font-bold text-emerald-700">Antigravity 画图参数 (OpenAI Compatible)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseSelect
v-model="form.config_json.size"
label="图像尺寸 (Size)"
:options="[
{ value: '1024x1024', label: '1024x1024 (1:1 正方形)' },
{ value: '1280x720', label: '1280x720 (16:9 横屏)' },
{ value: '720x1280', label: '720x1280 (9:16 竖屏)' },
{ value: '1216x896', label: '1216x896 (4:3 传统)' }
]"
placeholder="默认 1024x1024"
/>
<div class="flex items-center">
<p class="text-xs text-emerald-600">
💡 使用 Gemini 3 Pro Image 模型通过 OpenAI 兼容接口生成图像
</p>
</div>
</div>
</div>
<div class="md:col-span-2 flex justify-end gap-3 pt-4 border-t border-gray-100">
<BaseButton variant="secondary" type="button" @click="reset">取消</BaseButton>
<BaseButton type="submit">{{ form.id ? '保存变更' : '立即创建' }}</BaseButton>
</div>
</form>
</BaseCard>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
import BaseSelect from '../components/ui/BaseSelect.vue'
// Types
type Provider = {
id: string
name: string
type: string
adapter: string
model?: string
api_base?: string
has_api_key?: boolean
priority: number
enabled: boolean
config_ref?: string
weight?: number
timeout_ms?: number
max_retries?: number
config_json: Record<string, any>
}
// State
// State
const loginForm = ref({ username: '', password: '' })
const loginError = ref('')
const isLoggedIn = ref(!!sessionStorage.getItem('admin_auth'))
const activeTab = ref('text')
const providers = ref<Provider[]>([])
const defaults = ref<Record<string, string[]>>({})
const availableAdapters = ref<string[]>([])
const editing = ref(false)
const form = ref<Partial<Provider> & { api_key?: string; config_json: Record<string, any> }>({
type: 'text',
priority: 10,
enabled: true,
config_json: {}
})
const apiBase = import.meta.env.VITE_API_BASE || ''
function getAuthHeader(): string {
return sessionStorage.getItem('admin_auth') || ''
}
// Actions
async function login() {
loginError.value = ''
const auth = 'Basic ' + btoa(loginForm.value.username + ':' + loginForm.value.password)
try {
const res = await fetch(`${apiBase}/admin/providers`, {
headers: { Authorization: auth },
})
if (res.ok) {
sessionStorage.setItem('admin_auth', auth)
isLoggedIn.value = true
// 触发 NavBar 更新(如果在同一页面)- 实际上我们需要强制刷新或使用事件总线,但最简单的是 reload
// 不过 AdminProviders 是视图组件NavBar 是布局组件,它们状态不共享是个问题
// 鉴于 NavBar 也依赖 sessionStorage 但不响应,这里的页面刷新是必要的
window.location.reload()
} else {
loginError.value = '鉴权失败'
}
} catch {
loginError.value = '网络不可达'
}
}
function logout() {
sessionStorage.removeItem('admin_auth')
isLoggedIn.value = false
providers.value = []
window.location.reload()
}
async function loadData() {
if (!isLoggedIn.value) return
const headers = { Authorization: getAuthHeader() }
// Parallel fetch
const [pRes, dRes, aRes] = await Promise.all([
fetch(`${apiBase}/admin/providers`, { headers }),
fetch(`${apiBase}/admin/providers/defaults`, { headers }),
fetch(`${apiBase}/admin/providers/adapters`, { headers })
])
if (pRes.ok) providers.value = await pRes.json()
if (dRes.ok) defaults.value = await dRes.json()
if (aRes.ok) availableAdapters.value = await aRes.json()
}
// Computed
const filteredProviders = computed(() => {
return providers.value
.filter(p => p.type === activeTab.value)
.sort((a, b) => b.priority - a.priority)
})
const adapterOptions = computed(() => {
return availableAdapters.value
.filter(a => a.startsWith(activeTab.value + ':'))
.map(a => {
const name = a.split(':')[1]
return { value: name, label: name } // e.g. 'gemini', 'openai'
})
})
// UI Actions
function cloneDefault(type: string, name: string) {
// 根据默认名称推断配置
// 大多数默认 provider 的 name 就是 adapter name (如 gemini, openai, cqtai)
// 如果未来有别名,这里可以做映射
activeTab.value = type // 切换到对应 tab
form.value = {
type: type,
name: name,
adapter: name, // Default assumption
priority: 10,
enabled: true,
weight: 1,
timeout_ms: 60000,
max_retries: 1,
config_json: {}
}
editing.value = true
}
function openCreateModal() {
form.value = {
type: activeTab.value,
priority: 10,
enabled: true,
weight: 1,
timeout_ms: 60000,
max_retries: 1,
config_json: {}
}
editing.value = true
}
function edit(p: Provider) {
const { has_api_key, ...rest } = p
form.value = { ...rest, api_key: '', config_json: rest.config_json || {} } // Clear key for security, user re-enters if needed
editing.value = true
}
function reset() {
editing.value = false
form.value = { config_json: {} }
}
async function submit() {
const method = form.value.id ? 'PUT' : 'POST'
const url = form.value.id
? `${apiBase}/admin/providers/${form.value.id}`
: `${apiBase}/admin/providers`
await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader()
},
body: JSON.stringify(form.value)
})
await loadData()
reset()
}
async function remove(p: Provider) {
if(!confirm(`确认删除 ${p.name}?`)) return
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'DELETE',
headers: { Authorization: getAuthHeader() }
})
await loadData()
}
async function toggleEnabled(p: Provider) {
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader()
},
body: JSON.stringify({ enabled: !p.enabled })
})
await loadData()
}
onMounted(() => {
if (isLoggedIn.value) loadData()
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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