Browse Source

first commit

master
dark 3 days ago
commit
dc9da9078a
  1. 100
      .gitignore
  2. 66
      README.md
  3. 132
      TODOS/00-项目总览.md
  4. 148
      TODOS/01-环境搭建/01-APP-ReactNative环境搭建.md
  5. 107
      TODOS/01-环境搭建/02-Web前端Vue环境搭建.md
  6. 165
      TODOS/01-环境搭建/03-APP-ReactNative环境搭建.md
  7. 745
      TODOS/02-APP原型开发/01-项目初始化和模拟数据.md
  8. 479
      TODOS/02-APP原型开发/02-导航和布局设计.md
  9. 295
      TODOS/02-APP原型开发/03-登录页面.md
  10. 417
      TODOS/02-APP原型开发/04-首页.md
  11. 466
      TODOS/02-APP原型开发/05-体质辨识页面.md
  12. 387
      TODOS/02-APP原型开发/06-AI对话页面.md
  13. 383
      TODOS/02-APP原型开发/07-个人中心页面.md
  14. 195
      TODOS/02-后端开发/01-项目结构初始化.md
  15. 384
      TODOS/02-后端开发/02-数据库和模型设计.md
  16. 522
      TODOS/02-后端开发/03-用户认证模块.md
  17. 515
      TODOS/02-后端开发/04-健康调查模块.md
  18. 550
      TODOS/02-后端开发/05-体质辨识模块.md
  19. 833
      TODOS/02-后端开发/06-AI对话模块.md
  20. 512
      TODOS/02-后端开发/07-健康档案模块.md
  21. 673
      TODOS/02-后端开发/08-保健品商城关联模块.md
  22. 429
      TODOS/03-Web前端开发/01-项目结构初始化.md
  23. 594
      TODOS/03-Web前端开发/02-路由和布局设计.md
  24. 478
      TODOS/03-Web前端开发/03-用户认证页面.md
  25. 845
      TODOS/03-Web前端开发/04-健康调查页面.md
  26. 691
      TODOS/03-Web前端开发/05-体质辨识页面.md
  27. 616
      TODOS/03-Web前端开发/06-AI对话页面.md
  28. 588
      TODOS/03-Web前端开发/07-个人中心页面.md
  29. 277
      TODOS/03-Web原型开发/01-项目初始化和模拟数据.md
  30. 363
      TODOS/03-Web原型开发/02-路由和布局设计.md
  31. 271
      TODOS/03-Web原型开发/03-登录页面.md
  32. 391
      TODOS/03-Web原型开发/04-首页.md
  33. 626
      TODOS/03-Web原型开发/05-体质辨识页面.md
  34. 573
      TODOS/03-Web原型开发/06-AI对话页面.md
  35. 462
      TODOS/03-Web原型开发/07-个人中心页面.md
  36. 377
      TODOS/04-APP开发/01-项目结构初始化.md
  37. 448
      TODOS/04-APP开发/02-导航和布局设计.md
  38. 535
      TODOS/04-APP开发/03-用户认证页面.md
  39. 238
      TODOS/04-APP开发/04-健康调查页面.md
  40. 471
      TODOS/04-APP开发/05-体质辨识页面.md
  41. 476
      TODOS/04-APP开发/06-AI对话页面.md
  42. 494
      TODOS/04-APP开发/07-个人中心页面.md
  43. 185
      TODOS/04-后端开发/01-项目结构初始化.md
  44. 51
      TODOS/04-后端开发/02-数据库和模型设计.md
  45. 59
      TODOS/04-后端开发/03-用户认证模块.md
  46. 60
      TODOS/04-后端开发/04-健康调查模块.md
  47. 83
      TODOS/04-后端开发/05-体质辨识模块.md
  48. 96
      TODOS/04-后端开发/06-AI对话模块.md
  49. 96
      TODOS/04-后端开发/07-健康档案模块.md
  50. 92
      TODOS/04-后端开发/08-保健品商城关联模块.md
  51. 299
      TODOS/05-前后端对接/01-API服务对接.md
  52. 156
      TODOS/05-前后端对接/02-联调测试.md
  53. 4
      agents.md
  54. 1
      app
  55. 1049
      design.md
  56. BIN
      files/ui/体质分析.png
  57. BIN
      files/ui/体质检测.png
  58. BIN
      files/ui/体质页.png
  59. BIN
      files/ui/我的.png
  60. BIN
      files/ui/登录页.png
  61. BIN
      files/ui/问答对话.png
  62. BIN
      files/ui/问答页.png
  63. BIN
      files/ui/首页.png
  64. 878
      mall-design.md
  65. 30
      scripts/start-all.bat
  66. 13
      scripts/start-app.bat
  67. 12
      scripts/start-web.bat
  68. 57
      server/cmd/server/main.go
  69. 40
      server/config.yaml
  70. BIN
      server/data/health.db
  71. 551
      server/docs/API.md
  72. 58
      server/go.mod
  73. 133
      server/go.sum
  74. 141
      server/internal/api/handler/auth.go
  75. 155
      server/internal/api/handler/constitution.go
  76. 183
      server/internal/api/handler/conversation.go
  77. 201
      server/internal/api/handler/health.go
  78. 172
      server/internal/api/handler/product.go
  79. 256
      server/internal/api/handler/survey.go
  80. 72
      server/internal/api/middleware/auth.go
  81. 136
      server/internal/api/router.go
  82. 79
      server/internal/config/config.go
  83. 50
      server/internal/database/database.go
  84. 151
      server/internal/database/seed.go
  85. 145
      server/internal/model/constitution.go
  86. 35
      server/internal/model/conversation.go
  87. 46
      server/internal/model/health.go
  88. 31
      server/internal/model/models.go
  89. 66
      server/internal/model/product.go
  90. 64
      server/internal/model/user.go
  91. 71
      server/internal/repository/impl/constitution.go
  92. 78
      server/internal/repository/impl/conversation.go
  93. 127
      server/internal/repository/impl/health.go
  94. 99
      server/internal/repository/impl/product.go
  95. 42
      server/internal/repository/impl/user.go
  96. 66
      server/internal/repository/interface.go
  97. 165
      server/internal/service/ai/aliyun.go
  98. 26
      server/internal/service/ai/client.go
  99. 22
      server/internal/service/ai/factory.go
  100. 156
      server/internal/service/ai/openai.go

100
.gitignore

@ -0,0 +1,100 @@
# 依赖目录
node_modules/
.pnp/
.pnp.js
# 构建输出
dist/
build/
.output/
.nuxt/
# Expo / React Native
.expo/
.expo-shared/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
android/
ios/
# Vue.js
.vite/
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
vendor/
# 环境变量
.env
.env.local
.env.*.local
.env.development
.env.production
*.env
# 日志文件
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 编辑器/IDE
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.swp
*.swo
.project
.classpath
.settings/
# 系统文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# 测试覆盖率
coverage/
.nyc_output/
# 缓存
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
*.tsbuildinfo
# 临时文件
tmp/
temp/
*.tmp
*.temp
# 包管理锁文件 (可选,根据团队约定)
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# 本地配置
local.properties

66
README.md

@ -0,0 +1,66 @@
# 健康AI助手 - 原型项目
基于中医体质辨识理论的智能健康咨询平台原型。
## 快速启动
**Windows 用户:**
双击项目根目录的 `start.bat` 文件,选择要启动的服务。
或者分别启动:
```bash
# 启动 Web 原型
cd web && npm run dev
# 启动 APP 原型
cd app && npx expo start
```
## 测试账号
- **手机号**: `13800138000`
- **验证码**: `123456`
## 项目结构
```
healthApps/
├── web/ # Web 原型 (Vue 3 + Vite)
├── app/ # APP 原型 (React Native + Expo)
├── server/ # 后端服务 (Go + Gin)
├── scripts/ # 启动脚本
│ ├── start-web.bat
│ ├── start-app.bat
│ └── start-all.bat
├── start.bat # 主启动入口
├── design.md # 项目设计文档
└── TODOS/ # 开发任务文档
```
## 技术栈
| 项目 | 技术 | 端口 |
|-----|------|-----|
| Web | Vue 3 + TypeScript + Element Plus | 5173 |
| APP | React Native + Expo + Paper | 8081 |
| 后端 | Go + Gin + SQLite | 8080 |
## 功能模块
- **用户登录** - 手机号验证码登录(模拟)
- **首页** - 体质概览、快捷入口、健康提示
- **体质测试** - 20道问卷,本地计算体质类型
- **AI问答** - 模拟AI健康咨询对话
- **个人中心** - 用户信息、健康档案
## 原型说明
当前版本为原型演示版,使用本地模拟数据:
- 登录验证:模拟验证
- 体质测试:本地计算
- AI对话:关键词匹配模拟回复
- 数据存储:localStorage / AsyncStorage
## 后续开发
参考 `TODOS/` 目录下的开发文档进行后端对接。

132
TODOS/00-项目总览.md

@ -0,0 +1,132 @@
# 健康AI问询助手 - 开发总览
## 项目信息
- **项目名称**: 健康AI问询助手
- **技术栈**: Vue 3 + React Native + Go (Gin) + SQLite
- **开发模式**: 前端原型优先(使用模拟数据)
- **预计模块**: 5大阶段,25个开发任务
- **关联项目**: 保健品商城(外部系统)
---
## 开发阶段总览
> **开发策略**:先完成前端原型(APP + Web),使用模拟数据实现完整交互演示,后续再开发后端并对接。
### 第一阶段:环境搭建
| 序号 | 任务 | 文档 | 状态 |
|------|------|------|------|
| 1.1 | APP React Native 环境搭建 | `01-环境搭建/01-APP-ReactNative环境搭建.md` | ⬜ 待审议 |
| 1.2 | Web 前端 Vue 环境搭建 | `01-环境搭建/02-Web前端Vue环境搭建.md` | ⬜ 待审议 |
### 第二阶段:APP 原型开发(模拟数据)
| 序号 | 任务 | 文档 | 状态 |
|------|------|------|------|
| 2.1 | 项目初始化 + 模拟数据服务 | `02-APP原型开发/01-项目初始化和模拟数据.md` | ⬜ 待审议 |
| 2.2 | 导航和底部Tab设计 | `02-APP原型开发/02-导航和布局设计.md` | ⬜ 待审议 |
| 2.3 | 登录页面原型 | `02-APP原型开发/03-登录页面.md` | ⬜ 待审议 |
| 2.4 | 首页原型 | `02-APP原型开发/04-首页.md` | ⬜ 待审议 |
| 2.5 | 体质辨识页面原型 | `02-APP原型开发/05-体质辨识页面.md` | ⬜ 待审议 |
| 2.6 | AI对话页面原型 | `02-APP原型开发/06-AI对话页面.md` | ⬜ 待审议 |
| 2.7 | 个人中心页面原型 | `02-APP原型开发/07-个人中心页面.md` | ⬜ 待审议 |
### 第三阶段:Web 原型开发(模拟数据)
| 序号 | 任务 | 文档 | 状态 |
|------|------|------|------|
| 3.1 | 项目初始化 + 模拟数据服务 | `03-Web原型开发/01-项目初始化和模拟数据.md` | ⬜ 待审议 |
| 3.2 | 路由和布局设计 | `03-Web原型开发/02-路由和布局设计.md` | ⬜ 待审议 |
| 3.3 | 登录页面原型 | `03-Web原型开发/03-登录页面.md` | ⬜ 待审议 |
| 3.4 | 首页原型 | `03-Web原型开发/04-首页.md` | ⬜ 待审议 |
| 3.5 | 体质辨识页面原型 | `03-Web原型开发/05-体质辨识页面.md` | ⬜ 待审议 |
| 3.6 | AI对话页面原型 | `03-Web原型开发/06-AI对话页面.md` | ⬜ 待审议 |
| 3.7 | 个人中心页面原型 | `03-Web原型开发/07-个人中心页面.md` | ⬜ 待审议 |
### 第四阶段:后端开发
| 序号 | 任务 | 文档 | 状态 |
|------|------|------|------|
| 4.1 | Go环境搭建 + 项目初始化 | `04-后端开发/01-环境搭建和项目初始化.md` | ⬜ 待审议 |
| 4.2 | 数据库和模型设计 | `04-后端开发/02-数据库和模型设计.md` | ⬜ 待审议 |
| 4.3 | 用户认证模块 | `04-后端开发/03-用户认证模块.md` | ⬜ 待审议 |
| 4.4 | 健康调查模块 | `04-后端开发/04-健康调查模块.md` | ⬜ 待审议 |
| 4.5 | 体质辨识模块 | `04-后端开发/05-体质辨识模块.md` | ⬜ 待审议 |
| 4.6 | AI对话模块 | `04-后端开发/06-AI对话模块.md` | ⬜ 待审议 |
| 4.7 | 健康档案模块 | `04-后端开发/07-健康档案模块.md` | ⬜ 待审议 |
| 4.8 | 保健品商城关联模块 | `04-后端开发/08-保健品商城关联模块.md` | ⬜ 待审议 |
### 第五阶段:前后端对接
| 序号 | 任务 | 文档 | 状态 |
|------|------|------|------|
| 5.1 | APP 对接后端 API | `05-前后端对接/01-APP对接后端.md` | ⬜ 待审议 |
| 5.2 | Web 对接后端 API | `05-前后端对接/02-Web对接后端.md` | ⬜ 待审议 |
---
## 状态说明
| 标记 | 含义 |
|------|------|
| ⬜ | 待审议 |
| ✅ | 已通过 |
| 🔄 | 开发中 |
| ✔️ | 已完成 |
---
## 审议流程
1. 按顺序查看每个文档
2. 确认无误后标记"已通过"
3. 如需修改,在对话中说明
4. 通过后开始执行对应开发任务
---
## 依赖关系
```
第一阶段:环境搭建 (并行)
├── APP环境 (React Native)
└── Web环境 (Vue 3)
第二阶段:APP 原型开发 ──┐
├── 使用模拟数据,可完整交互演示
第三阶段:Web 原型开发 ──┘
第四阶段:后端开发 (顺序)
环境初始化 → 数据库模型 → 用户认证 → 健康调查 → 体质辨识 → AI对话 → 健康档案 → 保健品关联
第五阶段:前后端对接
APP对接 + Web对接 → 联调测试
```
---
## 模拟数据说明
原型阶段使用本地模拟数据,确保界面可完整交互:
| 模块 | 模拟数据内容 | 数据文件 |
|------|------------|----------|
| 用户认证 | 预设测试账号,模拟登录 | `mock/user.ts` |
| 体质测试 | 60道问卷题目,本地计算结果 | `mock/constitution.ts` |
| 体质结果 | 9种体质详细描述和建议 | `mock/constitution.ts` |
| AI对话 | 预设问答对,模拟多轮对话 | `mock/chat.ts` |
| 产品推荐 | 36条保健品数据 | `mock/products.ts` |
---
## 外部系统关联
| 系统 | 说明 | 对接方式 |
|------|------|----------|
| 保健品商城 | 关联保健品销售系统 | 产品链接跳转(mall_url) |
| 阿里云 AI | 通义千问大模型 | API 调用(后端对接)|
---
## 后续扩展(暂不开发)
| 功能 | 类型 | 备注 |
|------|------|------|
| 会员系统 | 积分制 | 消费/签到积分兑换权益 |

148
TODOS/01-环境搭建/01-APP-ReactNative环境搭建.md

@ -0,0 +1,148 @@
# 01-APP React Native 环境搭建
## 目标
搭建 React Native 开发环境,确保可以正常创建和运行项目。
---
## 环境要求
| 工具 | 版本要求 | 说明 |
|------|----------|------|
| Node.js | 18+ | JavaScript 运行环境 |
| npm/yarn | 最新版 | 包管理器 |
| JDK | 17+ | Android 编译需要 |
| Android Studio | 最新版 | Android 开发环境 |
| Xcode | 14+ | iOS 开发环境(仅 macOS) |
---
## 实施步骤
### 步骤 1:安装 Node.js
```bash
# 下载并安装 Node.js 18+
# https://nodejs.org/
# 验证安装
node -v
npm -v
```
### 步骤 2:安装 JDK(Android 开发)
```bash
# Windows:下载 OpenJDK 17
# https://adoptium.net/
# macOS:使用 Homebrew
brew install openjdk@17
# 配置环境变量
# JAVA_HOME 指向 JDK 安装目录
```
### 步骤 3:安装 Android Studio
1. 下载 Android Studio:https://developer.android.com/studio
2. 安装时选择以下组件:
- Android SDK
- Android SDK Platform
- Android Virtual Device
3. 打开 SDK Manager,安装:
- Android 14 (API 34)
- Android SDK Build-Tools 34
- Android SDK Command-line Tools
- Android Emulator
4. 配置环境变量:
```bash
# Windows (添加到系统环境变量)
ANDROID_HOME=C:\Users\<用户名>\AppData\Local\Android\Sdk
# macOS/Linux (添加到 ~/.zshrc 或 ~/.bashrc)
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_HOME/emulator
export PATH=$PATH:$ANDROID_HOME/platform-tools
```
### 步骤 4:创建 Android 模拟器
1. 打开 Android Studio
2. 进入 Device Manager
3. 创建新设备:
- 选择 Pixel 6
- 选择 API 34 系统镜像
- 完成创建
### 步骤 5:安装 Xcode(仅 macOS)
```bash
# 从 App Store 安装 Xcode
# 安装命令行工具
xcode-select --install
# 安装 CocoaPods
sudo gem install cocoapods
```
### 步骤 6:验证环境
```bash
# 创建测试项目
npx react-native init TestApp --template react-native-template-typescript
# 进入项目
cd TestApp
# 启动 Metro
npm start
# 新终端运行 Android
npm run android
# 或运行 iOS (macOS)
npm run ios
```
---
## 常见问题
### Android 构建失败
- 检查 ANDROID_HOME 环境变量
- 检查 JDK 版本是否为 17+
- 运行 `cd android && ./gradlew clean`
### iOS 构建失败
- 运行 `cd ios && pod install`
- 检查 Xcode 版本
### Metro 启动失败
- 清除缓存:`npm start -- --reset-cache`
- 删除 node_modules 重新安装
---
## 验收标准
- [ ] Node.js 18+ 已安装
- [ ] Android Studio 已安装
- [ ] Android 模拟器可正常启动
- [ ] 测试项目可在模拟器运行
- [ ] (macOS) Xcode 已安装
- [ ] (macOS) iOS 模拟器可正常运行
---
## 预计耗时
30-60 分钟(视网络情况)
---
## 下一步
完成后进入 `01-环境搭建/02-Web前端Vue环境搭建.md`

107
TODOS/01-环境搭建/02-Web前端Vue环境搭建.md

@ -0,0 +1,107 @@
# 02-Web 前端 Vue 环境搭建
## 目标
搭建 Vue 3 + TypeScript + Vite 开发环境。
---
## 前置要求
- Node.js 18+
- npm 或 pnpm 包管理器
---
## 实施步骤
### 步骤 1:安装 Node.js
**Windows:**
1. 下载 Node.js LTS:https://nodejs.org/
2. 选择 Windows Installer (.msi)
3. 双击安装,默认配置即可
4. 验证:
```bash
node -v
# 输出: v18.x.x 或更高
npm -v
# 输出: 9.x.x 或更高
```
**macOS:**
```bash
brew install node
node -v
npm -v
```
**Linux:**
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v
npm -v
```
### 步骤 2:配置 npm 镜像(国内加速)
```bash
npm config set registry https://registry.npmmirror.com
npm config get registry
# 输出: https://registry.npmmirror.com
```
### 步骤 3:安装 pnpm(推荐)
```bash
npm install -g pnpm
pnpm -v
```
### 步骤 4:验证 Vite 可用
```bash
# 创建测试项目
npm create vite@latest vue-test -- --template vue-ts
cd vue-test
npm install
npm run dev
# 浏览器访问 http://localhost:5173 看到 Vue 页面即成功
```
### 步骤 5:安装开发工具(可选)
VSCode 推荐扩展:
- Vue - Official(Vue 官方扩展)
- TypeScript Vue Plugin (Volar)
- ESLint
- Prettier
---
## 需要创建的文件
本步骤无需创建项目文件,仅环境配置。
---
## 验收标准
- [ ] `node -v` 显示 18+
- [ ] `npm config get registry` 显示国内镜像
- [ ] Vite 测试项目可以正常启动
---
## 预计耗时
10-15 分钟
---
## 下一步
完成后进入 `01-环境搭建/03-APP-ReactNative环境搭建.md`

165
TODOS/01-环境搭建/03-APP-ReactNative环境搭建.md

@ -0,0 +1,165 @@
# 03-APP React Native 环境搭建
## 目标
搭建 React Native 开发环境,支持 Android 和 iOS 开发。
---
## 前置要求
- Node.js 18+(已在上一步安装)
- JDK 17(Android 开发)
- Android Studio(Android 模拟器)
- Xcode(iOS 开发,仅 macOS)
---
## 实施步骤
### 步骤 1:安装 JDK 17
**Windows:**
1. 下载 OpenJDK 17:https://adoptium.net/
2. 选择 Windows x64 Installer
3. 安装并配置环境变量:
```
JAVA_HOME = C:\Program Files\Eclipse Adoptium\jdk-17.x.x
Path 添加 %JAVA_HOME%\bin
```
4. 验证:
```bash
java -version
# 输出: openjdk version "17.x.x"
```
**macOS:**
```bash
brew install openjdk@17
echo 'export JAVA_HOME=$(/usr/libexec/java_home -v17)' >> ~/.zshrc
source ~/.zshrc
java -version
```
### 步骤 2:安装 Android Studio
1. 下载:https://developer.android.com/studio
2. 安装时选择:
- Android SDK
- Android SDK Platform
- Android Virtual Device
3. 打开 Android Studio → SDK Manager
4. 安装 Android 13 (API 33) 或 Android 14 (API 34)
5. 配置环境变量:
**Windows:**
```
ANDROID_HOME = C:\Users\<用户名>\AppData\Local\Android\Sdk
Path 添加:
%ANDROID_HOME%\platform-tools
%ANDROID_HOME%\emulator
```
**macOS/Linux:**
```bash
echo 'export ANDROID_HOME=$HOME/Library/Android/sdk' >> ~/.zshrc
echo 'export PATH=$PATH:$ANDROID_HOME/platform-tools' >> ~/.zshrc
echo 'export PATH=$PATH:$ANDROID_HOME/emulator' >> ~/.zshrc
source ~/.zshrc
```
### 步骤 3:创建 Android 模拟器
1. 打开 Android Studio
2. Tools → Device Manager → Create Device
3. 选择 Pixel 6 或其他设备
4. 选择系统镜像(推荐 API 33/34)
5. 完成创建并启动模拟器
### 步骤 4:安装 React Native CLI
```bash
npm install -g react-native-cli
```
### 步骤 5:验证环境
```bash
# 创建测试项目
npx react-native init RNTest --template react-native-template-typescript
cd RNTest
# 启动 Metro bundler
npm start
# 新终端运行 Android
npm run android
# 或运行 iOS(仅 macOS)
npm run ios
```
### 步骤 6:iOS 环境(仅 macOS)
```bash
# 安装 Xcode(App Store)
# 安装命令行工具
xcode-select --install
# 安装 CocoaPods
sudo gem install cocoapods
# 在项目 ios 目录
cd ios && pod install && cd ..
```
---
## 常见问题
### Android 模拟器启动失败
- 确保 BIOS 开启虚拟化(VT-x / AMD-V)- Windows 需开启 Hyper-V 或 HAXM
### Metro bundler 端口占用
```bash
npx react-native start --port 8082
```
### Gradle 下载慢
编辑 `android/gradle/wrapper/gradle-wrapper.properties`,使用国内镜像
---
## 需要创建的文件
本步骤无需创建项目文件,仅环境配置。
---
## 验收标准
- [ ] `java -version` 显示 JDK 17
- [ ] `adb devices` 可列出模拟器/设备
- [ ] 测试项目在模拟器正常运行
---
## 预计耗时
30-60 分钟(主要是下载时间)
---
## 下一步
完成后进入 `02-后端开发/01-项目结构初始化.md`
\
'
][;plplpl"lkkjijijijijijiiiiiii]'

745
TODOS/02-APP原型开发/01-项目初始化和模拟数据.md

@ -0,0 +1,745 @@
# 01-项目初始化和模拟数据
## 目标
初始化 React Native 项目并搭建模拟数据服务,为原型开发提供完整的数据支持。
---
## 前置要求
- 已完成 React Native 环境搭建
- Node.js 18+
- Android Studio / Xcode
---
## 实施步骤
### 步骤 1:创建 React Native 项目
```bash
# 进入项目目录
cd I:/apps/demo/healthApps
# 创建 React Native 项目
npx react-native init HealthAIApp --template react-native-template-typescript
# 进入项目
cd HealthAIApp
```
### 步骤 2:安装核心依赖
```bash
# 导航
npm install @react-navigation/native @react-navigation/bottom-tabs @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context
# UI 组件库
npm install react-native-paper react-native-vector-icons
# 状态管理
npm install zustand
# 图表
npm install react-native-gifted-charts react-native-linear-gradient react-native-svg
# 存储
npm install @react-native-async-storage/async-storage
# 表单
npm install react-hook-form
# 工具
npm install dayjs lodash-es
npm install -D @types/lodash-es
```
### 步骤 3:创建项目目录结构
```
HealthAIApp/
├── src/
│ ├── api/ # API 接口(后续对接用)
│ ├── components/ # 公共组件
│ │ ├── Button.tsx
│ │ ├── Card.tsx
│ │ ├── Input.tsx
│ │ └── Loading.tsx
│ ├── mock/ # 模拟数据 ⭐
│ │ ├── index.ts # 统一导出
│ │ ├── user.ts # 用户数据
│ │ ├── constitution.ts # 体质问卷和结果
│ │ ├── chat.ts # AI 对话数据
│ │ └── products.ts # 产品数据
│ ├── navigation/ # 导航配置
│ │ └── index.tsx
│ ├── screens/ # 页面
│ │ ├── auth/ # 登录相关
│ │ ├── home/ # 首页
│ │ ├── constitution/ # 体质辨识
│ │ ├── chat/ # AI 对话
│ │ └── profile/ # 个人中心
│ ├── services/ # 业务服务
│ │ └── mockService.ts # 模拟服务
│ ├── stores/ # Zustand 状态
│ │ ├── useAuthStore.ts
│ │ ├── useConstitutionStore.ts
│ │ └── useChatStore.ts
│ ├── theme/ # 主题配置
│ │ └── index.ts
│ ├── types/ # TypeScript 类型
│ │ └── index.ts
│ └── utils/ # 工具函数
│ └── index.ts
├── App.tsx
└── package.json
```
### 步骤 4:创建模拟数据服务
#### 4.1 类型定义 `src/types/index.ts`
```typescript
// 用户类型
export interface User {
id: number;
phone: string;
nickname: string;
avatar: string;
surveyCompleted: boolean;
}
// 体质类型
export type ConstitutionType =
| 'pinghe' // 平和质
| 'qixu' // 气虚质
| 'yangxu' // 阳虚质
| 'yinxu' // 阴虚质
| 'tanshi' // 痰湿质
| 'shire' // 湿热质
| 'xueyu' // 血瘀质
| 'qiyu' // 气郁质
| 'tebing'; // 特禀质
// 体质问卷题目
export interface ConstitutionQuestion {
id: number;
constitutionType: ConstitutionType;
question: string;
options: { value: number; label: string }[];
}
// 体质评估结果
export interface ConstitutionResult {
primaryType: ConstitutionType;
scores: Record<ConstitutionType, number>;
description: string;
suggestions: string[];
assessedAt: string;
}
// 对话消息
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: string;
}
// 对话
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: string;
updatedAt: string;
}
// 产品
export interface Product {
id: number;
name: string;
category: string;
description: string;
efficacy: string;
price: number;
imageUrl: string;
mallUrl: string;
}
```
#### 4.2 用户模拟数据 `src/mock/user.ts`
```typescript
import { User } from '../types';
// 测试用户
export const mockUsers: User[] = [
{
id: 1,
phone: '13800138000',
nickname: '健康达人',
avatar: 'https://api.dicebear.com/7.x/avataaars/png?seed=1',
surveyCompleted: true,
},
{
id: 2,
phone: '13900139000',
nickname: '新用户',
avatar: 'https://api.dicebear.com/7.x/avataaars/png?seed=2',
surveyCompleted: false,
},
];
// 模拟登录
export const mockLogin = (phone: string, code: string): Promise<User | null> => {
return new Promise((resolve) => {
setTimeout(() => {
// 验证码固定为 123456
if (code !== '123456') {
resolve(null);
return;
}
const user = mockUsers.find((u) => u.phone === phone);
resolve(user || mockUsers[0]); // 默认返回第一个用户
}, 800);
});
};
```
#### 4.3 体质问卷模拟数据 `src/mock/constitution.ts`
```typescript
import { ConstitutionQuestion, ConstitutionResult, ConstitutionType } from '../types';
// 体质类型中文名
export const constitutionNames: Record<ConstitutionType, string> = {
pinghe: '平和质',
qixu: '气虚质',
yangxu: '阳虚质',
yinxu: '阴虚质',
tanshi: '痰湿质',
shire: '湿热质',
xueyu: '血瘀质',
qiyu: '气郁质',
tebing: '特禀质',
};
// 体质描述
export const constitutionDescriptions: Record<ConstitutionType, {
description: string;
features: string[];
suggestions: string[];
}> = {
pinghe: {
description: '阴阳气血调和,体态适中,面色红润,精力充沛',
features: ['体态匀称', '面色红润', '精力充沛', '睡眠良好', '性格开朗'],
suggestions: ['保持均衡饮食', '适度运动', '规律作息', '心态平和'],
},
qixu: {
description: '元气不足,容易疲乏,气短懒言,易出汗',
features: ['容易疲劳', '气短懒言', '易出汗', '抵抗力差', '声音低弱'],
suggestions: ['多食补气食物(黄芪、人参、山药)', '避免过度劳累', '适当午休', '温和运动'],
},
yangxu: {
description: '阳气不足,畏寒怕冷,手脚冰凉,精神不振',
features: ['畏寒怕冷', '手脚冰凉', '喜热饮食', '精神不振', '小便清长'],
suggestions: ['多食温阳食物(羊肉、生姜、桂圆)', '注意保暖', '避免寒凉', '晒太阳'],
},
yinxu: {
description: '阴液亏少,口燥咽干,手足心热,易失眠',
features: ['口干咽燥', '手足心热', '失眠多梦', '皮肤干燥', '大便干结'],
suggestions: ['多食滋阴食物(枸杞、银耳、百合)', '避免熬夜', '少食辛辣', '保持心情平静'],
},
tanshi: {
description: '痰湿凝聚,体形肥胖,腹部肥满,胸闷痰多',
features: ['体形肥胖', '腹部肥满', '胸闷痰多', '身重乏力', '面部油腻'],
suggestions: ['控制饮食', '多食祛湿食物(薏米、冬瓜)', '加强运动', '少食甜腻'],
},
shire: {
description: '湿热内蕴,面垢油光,口苦口干,身重困倦',
features: ['面部油腻', '口苦口干', '身重困倦', '大便黏滞', '小便短黄'],
suggestions: ['清淡饮食', '多食清热利湿食物', '避免辛辣油腻', '保持环境干燥'],
},
xueyu: {
description: '血行不畅,面色晦暗,皮肤粗糙,易生斑点',
features: ['面色晦暗', '皮肤粗糙', '易生色斑', '唇色偏暗', '健忘'],
suggestions: ['多食活血食物(山楂、黑木耳)', '适当运动', '避免久坐', '保持心情舒畅'],
},
qiyu: {
description: '气机郁滞,情志抑郁,忧虑脆弱,胸胁胀满',
features: ['情绪低落', '忧虑善感', '胸胁胀满', '咽部异物感', '睡眠不佳'],
suggestions: ['疏肝理气', '多食理气食物(玫瑰花、陈皮)', '保持心情愉快', '多交流倾诉'],
},
tebing: {
description: '先天失常,易过敏,适应能力差',
features: ['易过敏', '喷嚏频繁', '皮肤易起疹', '适应力差', '遗传倾向'],
suggestions: ['避免过敏原', '增强体质', '饮食清淡', '注意环境卫生'],
},
};
// 体质问卷题目(共60题,每种体质7题,平和质4题)
export const constitutionQuestions: ConstitutionQuestion[] = [
// 气虚质 (7题)
{ id: 1, constitutionType: 'qixu', question: '您容易疲乏吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 2, constitutionType: 'qixu', question: '您容易气短(呼吸短促,接不上气)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 3, constitutionType: 'qixu', question: '您容易心慌吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 4, constitutionType: 'qixu', question: '您容易头晕或站起时晕眩吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 5, constitutionType: 'qixu', question: '您比别人容易感冒吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 6, constitutionType: 'qixu', question: '您喜欢安静、懒得说话吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 7, constitutionType: 'qixu', question: '您说话声音低弱无力吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 阳虚质 (7题)
{ id: 8, constitutionType: 'yangxu', question: '您手脚发凉吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 9, constitutionType: 'yangxu', question: '您胃脘部、背部或腰膝部怕冷吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 10, constitutionType: 'yangxu', question: '您比一般人耐受不了寒冷吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 11, constitutionType: 'yangxu', question: '您容易感受风寒吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 12, constitutionType: 'yangxu', question: '您吃(喝)凉的东西会感到不舒服或者怕吃凉的东西吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 13, constitutionType: 'yangxu', question: '您受凉或吃凉的东西后,容易拉肚子吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 14, constitutionType: 'yangxu', question: '您比别人更容易患感冒吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 阴虚质 (7题)
{ id: 15, constitutionType: 'yinxu', question: '您感到手脚心发热吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 16, constitutionType: 'yinxu', question: '您感觉身体、脸上发热吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 17, constitutionType: 'yinxu', question: '您皮肤或口唇干吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 18, constitutionType: 'yinxu', question: '您口唇的颜色比一般人红吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 19, constitutionType: 'yinxu', question: '您容易便秘或大便干燥吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 20, constitutionType: 'yinxu', question: '您面部两颧潮红或偏红吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 21, constitutionType: 'yinxu', question: '您感到眼睛干涩吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 痰湿质 (7题)
{ id: 22, constitutionType: 'tanshi', question: '您感到胸闷或腹部胀满吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 23, constitutionType: 'tanshi', question: '您感到身体沉重不轻松或不爽快吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 24, constitutionType: 'tanshi', question: '您腹部肥满松软吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 25, constitutionType: 'tanshi', question: '您有额部油脂分泌多的现象吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 26, constitutionType: 'tanshi', question: '您上眼睑比别人肿(上眼睑有轻微隆起的现象)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 27, constitutionType: 'tanshi', question: '您嘴里有黏黏的感觉吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 28, constitutionType: 'tanshi', question: '您平时痰多,特别是咽喉部总感到有痰堵着吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 湿热质 (7题)
{ id: 29, constitutionType: 'shire', question: '您面部或鼻部有油腻感或者油亮发光吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 30, constitutionType: 'shire', question: '您容易生痤疮或者疮疖吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 31, constitutionType: 'shire', question: '您感到口苦或嘴里有异味吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 32, constitutionType: 'shire', question: '您大便黏滞不爽、有解不尽的感觉吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 33, constitutionType: 'shire', question: '您小便时尿道有发热感、尿色浓(深)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 34, constitutionType: 'shire', question: '您带下色黄(白带颜色发黄)吗?(限女性回答)', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 35, constitutionType: 'shire', question: '您的阴囊部位潮湿吗?(限男性回答)', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 血瘀质 (7题)
{ id: 36, constitutionType: 'xueyu', question: '您的皮肤在不知不觉中会出现青紫瘀斑(皮下出血)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 37, constitutionType: 'xueyu', question: '您两颧部有细微红丝(毛细血管扩张)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 38, constitutionType: 'xueyu', question: '您身体上有哪里疼痛吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 39, constitutionType: 'xueyu', question: '您面色晦暗或容易出现褐斑吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 40, constitutionType: 'xueyu', question: '您容易有黑眼圈吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 41, constitutionType: 'xueyu', question: '您容易忘事(健忘)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 42, constitutionType: 'xueyu', question: '您口唇颜色偏暗吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 气郁质 (7题)
{ id: 43, constitutionType: 'qiyu', question: '您感到闷闷不乐、情绪低沉吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 44, constitutionType: 'qiyu', question: '您容易精神紧张、焦虑不安吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 45, constitutionType: 'qiyu', question: '您多愁善感、感情脆弱吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 46, constitutionType: 'qiyu', question: '您容易感到害怕或受到惊吓吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 47, constitutionType: 'qiyu', question: '您胁肋部或乳房胀痛吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 48, constitutionType: 'qiyu', question: '您无缘无故叹气吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 49, constitutionType: 'qiyu', question: '您咽喉部有异物感,且吐之不出、咽之不下吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 特禀质 (7题)
{ id: 50, constitutionType: 'tebing', question: '您没有感冒时也会打喷嚏吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 51, constitutionType: 'tebing', question: '您没有感冒时也会鼻塞、流鼻涕吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 52, constitutionType: 'tebing', question: '您有因季节变化、温度变化或异味等原因而咳喘的现象吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 53, constitutionType: 'tebing', question: '您容易过敏(对药物、食物、气味、花粉或在季节交替、气候变化时)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 54, constitutionType: 'tebing', question: '您的皮肤容易起荨麻疹(风团、风疹块、风疙瘩)吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 55, constitutionType: 'tebing', question: '您的皮肤一抓就红,并出现抓痕吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 56, constitutionType: 'tebing', question: '您的皮肤在不知不觉中会出现紫红色瘀点、瘀斑吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
// 平和质 (4题)
{ id: 57, constitutionType: 'pinghe', question: '您精力充沛吗?', options: [
{ value: 5, label: '总是' }, { value: 4, label: '经常' }, { value: 3, label: '有时' }, { value: 2, label: '很少' }, { value: 1, label: '从不' }
]},
{ id: 58, constitutionType: 'pinghe', question: '您容易疲乏吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 59, constitutionType: 'pinghe', question: '您说话声音低弱无力吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
{ id: 60, constitutionType: 'pinghe', question: '您感到闷闷不乐、情绪低沉吗?', options: [
{ value: 1, label: '从不' }, { value: 2, label: '很少' }, { value: 3, label: '有时' }, { value: 4, label: '经常' }, { value: 5, label: '总是' }
]},
];
// 计算体质结果
export const calculateConstitution = (
answers: Record<number, number>
): ConstitutionResult => {
const scores: Record<ConstitutionType, number> = {
pinghe: 0, qixu: 0, yangxu: 0, yinxu: 0, tanshi: 0,
shire: 0, xueyu: 0, qiyu: 0, tebing: 0,
};
// 计算各体质得分
constitutionQuestions.forEach((q) => {
const answer = answers[q.id] || 3;
scores[q.constitutionType] += answer;
});
// 转换为百分制
const questionCounts: Record<ConstitutionType, number> = {
pinghe: 4, qixu: 7, yangxu: 7, yinxu: 7, tanshi: 7,
shire: 7, xueyu: 7, qiyu: 7, tebing: 7,
};
Object.keys(scores).forEach((key) => {
const type = key as ConstitutionType;
const count = questionCounts[type];
// 转换公式:(原始分 - 题目数) / (题目数 * 4) * 100
scores[type] = Math.round(((scores[type] - count) / (count * 4)) * 100);
});
// 平和质特殊处理(反向计算)
scores.pinghe = 100 - scores.pinghe;
// 找出主体质(平和质需要特殊判断)
let primaryType: ConstitutionType = 'pinghe';
// 平和质判定:平和质得分≥60,且其他偏颇体质得分<40
const isPinghe = scores.pinghe >= 60 &&
Object.entries(scores)
.filter(([k]) => k !== 'pinghe')
.every(([, v]) => v < 40);
if (!isPinghe) {
// 找最高分的偏颇体质
let maxScore = 0;
Object.entries(scores).forEach(([type, score]) => {
if (type !== 'pinghe' && score > maxScore) {
maxScore = score;
primaryType = type as ConstitutionType;
}
});
}
const info = constitutionDescriptions[primaryType];
return {
primaryType,
scores,
description: info.description,
suggestions: info.suggestions,
assessedAt: new Date().toISOString(),
};
};
```
#### 4.4 AI对话模拟数据 `src/mock/chat.ts`
```typescript
import { Conversation, Message } from '../types';
// 预设对话模板
export const chatTemplates: Record<string, string[]> = {
greeting: [
'您好!我是健康AI助手,很高兴为您服务。',
'我可以根据您的体质特点,为您提供个性化的健康建议。',
'请问有什么可以帮助您的吗?',
],
fatigue: [
'【情况分析】根据您描述的疲劳症状,结合您的气虚体质,这可能与气血不足有关。',
'【建议】\n1. 保证充足睡眠,每天7-8小时\n2. 适当运动,如太极、散步\n3. 饮食上多吃补气食物',
'【用药参考】\n- 黄芪精口服液:每日2次,每次1支(建议咨询药师)',
'【推荐调养产品】\n- 黄芪精口服液 ¥68 [点击购买](https://mall.example.com/product/1)',
'【提醒】如果疲劳症状持续超过2周且伴有其他不适,建议就医检查。',
],
sleep: [
'【情况分析】失眠问题可能与您的阴虚体质有关,阴虚容易导致心神不宁。',
'【建议】\n1. 睡前避免使用电子设备\n2. 保持卧室温度适宜\n3. 睡前可以泡脚、喝温牛奶',
'【用药参考】\n- 酸枣仁膏:睡前30分钟服用(建议咨询药师)',
'【推荐调养产品】\n- 酸枣仁百合膏 ¥58 [点击购买](https://mall.example.com/product/30)',
'【提醒】如长期严重失眠,建议到医院睡眠科就诊。',
],
joint: [
'【情况分析】关节疼痛在中老年人群中较为常见,可能与骨关节退化有关。',
'【建议】\n1. 适度运动,避免长时间保持同一姿势\n2. 注意关节保暖\n3. 控制体重减轻关节负担',
'【用药参考】\n- 氨糖软骨素:每日1-2次,每次2粒(建议咨询药师)',
'【推荐调养产品】\n- 氨糖软骨素钙片 ¥168 [点击购买](https://mall.example.com/product/24)',
'【提醒】如关节疼痛加重或出现红肿,请及时就医。',
],
default: [
'感谢您的咨询!根据您的描述,我为您提供以下建议:',
'1. 保持良好的作息习惯\n2. 均衡饮食,多吃蔬果\n3. 适当运动,增强体质',
'如果症状持续或加重,建议您及时就医检查。还有其他问题吗?',
],
};
// 模拟对话历史
export const mockConversations: Conversation[] = [
{
id: '1',
title: '关于疲劳的咨询',
messages: [
{ id: '1-1', role: 'user', content: '最近总是感觉很累,没精神', createdAt: '2024-01-15T10:00:00Z' },
{ id: '1-2', role: 'assistant', content: chatTemplates.fatigue.join('\n\n'), createdAt: '2024-01-15T10:00:05Z' },
],
createdAt: '2024-01-15T10:00:00Z',
updatedAt: '2024-01-15T10:00:05Z',
},
{
id: '2',
title: '睡眠问题咨询',
messages: [
{ id: '2-1', role: 'user', content: '晚上睡不着觉怎么办', createdAt: '2024-01-14T22:00:00Z' },
{ id: '2-2', role: 'assistant', content: chatTemplates.sleep.join('\n\n'), createdAt: '2024-01-14T22:00:05Z' },
],
createdAt: '2024-01-14T22:00:00Z',
updatedAt: '2024-01-14T22:00:05Z',
},
];
// 模拟AI回复
export const mockAIReply = (message: string): Promise<string> => {
return new Promise((resolve) => {
setTimeout(() => {
// 简单关键词匹配
const lowerMsg = message.toLowerCase();
if (lowerMsg.includes('累') || lowerMsg.includes('疲劳') || lowerMsg.includes('没精神')) {
resolve(chatTemplates.fatigue.join('\n\n'));
} else if (lowerMsg.includes('睡') || lowerMsg.includes('失眠')) {
resolve(chatTemplates.sleep.join('\n\n'));
} else if (lowerMsg.includes('关节') || lowerMsg.includes('腿疼') || lowerMsg.includes('膝盖')) {
resolve(chatTemplates.joint.join('\n\n'));
} else if (lowerMsg.includes('你好') || lowerMsg.includes('在吗')) {
resolve(chatTemplates.greeting.join('\n\n'));
} else {
resolve(chatTemplates.default.join('\n\n'));
}
}, 1500); // 模拟网络延迟
});
};
```
#### 4.5 产品模拟数据 `src/mock/products.ts`
```typescript
import { Product } from '../types';
export const mockProducts: Product[] = [
// 补气类
{ id: 1, name: '黄芪精口服液', category: '补气类', description: '补气固表,增强免疫力', efficacy: '适用于气虚质、易疲劳人群', price: 68, imageUrl: '', mallUrl: 'https://mall.example.com/product/1' },
{ id: 2, name: '人参蜂王浆', category: '补气类', description: '补气养血,改善疲劳', efficacy: '适用于气虚质、体力不足人群', price: 128, imageUrl: '', mallUrl: 'https://mall.example.com/product/2' },
// 温阳类
{ id: 4, name: '鹿茸参精胶囊', category: '温阳类', description: '温肾壮阳,补气养血', efficacy: '适用于阳虚质、畏寒怕冷人群', price: 268, imageUrl: '', mallUrl: 'https://mall.example.com/product/4' },
{ id: 5, name: '桂圆红枣茶', category: '温阳类', description: '温中补血,养心安神', efficacy: '适用于阳虚质、手脚冰凉人群', price: 45, imageUrl: '', mallUrl: 'https://mall.example.com/product/5' },
// 滋阴类
{ id: 6, name: '枸杞原浆', category: '滋阴类', description: '滋补肝肾,明目润肺', efficacy: '适用于阴虚质、眼睛干涩人群', price: 158, imageUrl: '', mallUrl: 'https://mall.example.com/product/6' },
// 心脑血管类
{ id: 21, name: '深海鱼油软胶囊', category: '心脑血管类', description: '辅助降血脂,保护心脑血管', efficacy: '适用于高血脂、动脉硬化人群', price: 128, imageUrl: '', mallUrl: 'https://mall.example.com/product/21' },
{ id: 22, name: '纳豆激酶胶囊', category: '心脑血管类', description: '溶解血栓,改善血液循环', efficacy: '适用于中老年心脑血管亚健康人群', price: 198, imageUrl: '', mallUrl: 'https://mall.example.com/product/22' },
// 骨关节类
{ id: 24, name: '氨糖软骨素钙片', category: '骨关节类', description: '修复软骨,润滑关节,补充钙质', efficacy: '适用于关节疼痛、骨质疏松人群', price: 168, imageUrl: '', mallUrl: 'https://mall.example.com/product/24' },
{ id: 25, name: '液体钙维D软胶囊', category: '骨关节类', description: '补钙,促进钙吸收,预防骨质疏松', efficacy: '适用于中老年人、骨质疏松人群', price: 78, imageUrl: '', mallUrl: 'https://mall.example.com/product/25' },
// 助眠安神类
{ id: 29, name: '褪黑素维生素B6片', category: '助眠安神类', description: '改善睡眠,调节生物钟', efficacy: '适用于失眠、睡眠质量差人群', price: 68, imageUrl: '', mallUrl: 'https://mall.example.com/product/29' },
{ id: 30, name: '酸枣仁百合膏', category: '助眠安神类', description: '养心安神,改善睡眠', efficacy: '适用于心烦失眠、多梦易醒人群', price: 58, imageUrl: '', mallUrl: 'https://mall.example.com/product/30' },
// 增强免疫类
{ id: 36, name: '灵芝孢子粉胶囊', category: '增强免疫类', description: '增强免疫力,抗疲劳', efficacy: '适用于免疫力低下、体质虚弱人群', price: 298, imageUrl: '', mallUrl: 'https://mall.example.com/product/36' },
];
// 根据体质获取推荐产品
export const getProductsByConstitution = (constitutionType: string): Product[] => {
const mapping: Record<string, number[]> = {
qixu: [1, 2, 36],
yangxu: [4, 5],
yinxu: [6, 30],
tanshi: [9, 10],
xueyu: [21, 22],
pinghe: [20, 36],
};
const ids = mapping[constitutionType] || [20, 36];
return mockProducts.filter((p) => ids.includes(p.id));
};
```
#### 4.6 统一导出 `src/mock/index.ts`
```typescript
export * from './user';
export * from './constitution';
export * from './chat';
export * from './products';
```
### 步骤 5:创建主题配置
创建 `src/theme/index.ts`
```typescript
import { MD3LightTheme } from 'react-native-paper';
export const theme = {
...MD3LightTheme,
colors: {
...MD3LightTheme.colors,
primary: '#52C41A', // 主色调 - 健康绿
secondary: '#1890FF', // 辅助色 - 蓝色
background: '#F5F5F5', // 背景色
surface: '#FFFFFF', // 卡片背景
error: '#FF4D4F', // 错误色
text: '#333333', // 主要文字
textSecondary: '#666666', // 次要文字
border: '#E8E8E8', // 边框色
},
roundness: 12, // 圆角
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
};
```
---
## 验收标准
- [ ] React Native 项目创建成功
- [ ] 所有依赖安装完成
- [ ] 目录结构创建完成
- [ ] 模拟数据文件创建完成
- [ ] 类型定义完整
- [ ] 项目可正常启动(空白页面)
---
## 预计耗时
40-60 分钟
---
## 下一步
完成后进入 `02-APP原型开发/02-导航和布局设计.md`

479
TODOS/02-APP原型开发/02-导航和布局设计.md

@ -0,0 +1,479 @@
# 02-导航和布局设计
## 目标
配置 React Navigation 导航系统,实现 Tab 导航和 Stack 导航。
---
## UI 设计参考
> 参考设计稿:`files/ui/首页.png`
### 底部 Tab 导航规范
| Tab | 图标 | 文字 |
|-----|------|------|
| 首页 | 房屋图标 `home` | 首页 |
| AI问答 | 对话气泡 `chat-processing` | AI问答 |
| 体质分析 | 心电图 `chart-line-variant` | 体质分析 |
| 我的 | 用户图标 `account` | 我的 |
### Tab 样式规范
```typescript
const tabBarOptions = {
activeTintColor: '#10B981', // 选中颜色
inactiveTintColor: '#9CA3AF', // 未选中颜色
style: {
height: 60,
paddingBottom: 8,
paddingTop: 8,
backgroundColor: '#FFFFFF',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
},
labelStyle: {
fontSize: 12,
},
}
```
---
## 前置要求
- 项目结构已初始化
- React Navigation 已安装
- 模拟数据服务已创建
---
## 实施步骤
### 步骤 1:创建导航类型定义
创建 `src/navigation/types.ts`
```typescript
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'
import type { CompositeNavigationProp, RouteProp } from '@react-navigation/native'
// Root Stack
export type RootStackParamList = {
Auth: undefined
Main: undefined
}
// Auth Stack
export type AuthStackParamList = {
Login: undefined
}
// Main Tab
export type MainTabParamList = {
HomeTab: undefined
ChatTab: undefined
ConstitutionTab: undefined
ProfileTab: undefined
}
// Home Stack
export type HomeStackParamList = {
Home: undefined
}
// Chat Stack
export type ChatStackParamList = {
ChatList: undefined
ChatDetail: { id: string }
}
// Constitution Stack
export type ConstitutionStackParamList = {
ConstitutionHome: undefined
ConstitutionQuestions: undefined
ConstitutionResult: undefined
}
// Profile Stack
export type ProfileStackParamList = {
ProfileHome: undefined
HealthRecord: undefined
}
// Navigation Props
export type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>
export type ChatNavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<ChatStackParamList>,
BottomTabNavigationProp<MainTabParamList>
>
// Route Props
export type ChatDetailRouteProp = RouteProp<ChatStackParamList, 'ChatDetail'>
```
### 步骤 2:创建认证状态 Store
创建 `src/stores/useAuthStore.ts`
```typescript
import { create } from 'zustand'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { User } from '../types'
interface AuthState {
isLoggedIn: boolean
user: User | null
login: (user: User) => void
logout: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
isLoggedIn: false,
user: null,
login: (user) => {
AsyncStorage.setItem('user', JSON.stringify(user))
set({ isLoggedIn: true, user })
},
logout: () => {
AsyncStorage.removeItem('user')
set({ isLoggedIn: false, user: null })
},
}))
```
### 步骤 3:创建主 Tab 导航
创建 `src/navigation/MainTabNavigator.tsx`
```typescript
import React from 'react'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import type { MainTabParamList } from './types'
import HomeNavigator from './HomeNavigator'
import ChatNavigator from './ChatNavigator'
import ConstitutionNavigator from './ConstitutionNavigator'
import ProfileNavigator from './ProfileNavigator'
const Tab = createBottomTabNavigator<MainTabParamList>()
const MainTabNavigator = () => {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#10B981',
tabBarInactiveTintColor: '#9CA3AF',
tabBarStyle: {
height: 60,
paddingBottom: 8,
paddingTop: 8,
},
tabBarLabelStyle: {
fontSize: 12,
},
}}
>
<Tab.Screen
name="HomeTab"
component={HomeNavigator}
options={{
tabBarLabel: '首页',
tabBarIcon: ({ color, size }) => (
<Icon name="home" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="ChatTab"
component={ChatNavigator}
options={{
tabBarLabel: 'AI问答',
tabBarIcon: ({ color, size }) => (
<Icon name="chat-processing" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="ConstitutionTab"
component={ConstitutionNavigator}
options={{
tabBarLabel: '体质分析',
tabBarIcon: ({ color, size }) => (
<Icon name="chart-line-variant" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileNavigator}
options={{
tabBarLabel: '我的',
tabBarIcon: ({ color, size }) => (
<Icon name="account" size={size} color={color} />
),
}}
/>
</Tab.Navigator>
)
}
export default MainTabNavigator
```
### 步骤 4:创建子导航器
创建 `src/navigation/HomeNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { HomeStackParamList } from './types'
import HomeScreen from '../screens/home/HomeScreen'
const Stack = createNativeStackNavigator<HomeStackParamList>()
const HomeNavigator = () => {
return (
<Stack.Navigator>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
title: '健康AI助手',
headerStyle: { backgroundColor: '#10B981' },
headerTintColor: '#fff',
}}
/>
</Stack.Navigator>
)
}
export default HomeNavigator
```
创建 `src/navigation/ChatNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ChatStackParamList } from './types'
import ChatListScreen from '../screens/chat/ChatListScreen'
import ChatDetailScreen from '../screens/chat/ChatDetailScreen'
const Stack = createNativeStackNavigator<ChatStackParamList>()
const ChatNavigator = () => {
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#10B981' },
headerTintColor: '#fff',
}}
>
<Stack.Screen
name="ChatList"
component={ChatListScreen}
options={{ title: 'AI问答' }}
/>
<Stack.Screen
name="ChatDetail"
component={ChatDetailScreen}
options={{ title: '健康咨询' }}
/>
</Stack.Navigator>
)
}
export default ChatNavigator
```
创建 `src/navigation/ConstitutionNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ConstitutionStackParamList } from './types'
import ConstitutionHomeScreen from '../screens/constitution/ConstitutionHomeScreen'
import ConstitutionQuestionsScreen from '../screens/constitution/ConstitutionQuestionsScreen'
import ConstitutionResultScreen from '../screens/constitution/ConstitutionResultScreen'
const Stack = createNativeStackNavigator<ConstitutionStackParamList>()
const ConstitutionNavigator = () => {
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#10B981' },
headerTintColor: '#fff',
}}
>
<Stack.Screen
name="ConstitutionHome"
component={ConstitutionHomeScreen}
options={{ title: '体质分析' }}
/>
<Stack.Screen
name="ConstitutionQuestions"
component={ConstitutionQuestionsScreen}
options={{ title: '体质问卷' }}
/>
<Stack.Screen
name="ConstitutionResult"
component={ConstitutionResultScreen}
options={{ title: '测评结果' }}
/>
</Stack.Navigator>
)
}
export default ConstitutionNavigator
```
创建 `src/navigation/ProfileNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ProfileStackParamList } from './types'
import ProfileHomeScreen from '../screens/profile/ProfileHomeScreen'
import HealthRecordScreen from '../screens/profile/HealthRecordScreen'
const Stack = createNativeStackNavigator<ProfileStackParamList>()
const ProfileNavigator = () => {
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#10B981' },
headerTintColor: '#fff',
}}
>
<Stack.Screen
name="ProfileHome"
component={ProfileHomeScreen}
options={{ title: '我的' }}
/>
<Stack.Screen
name="HealthRecord"
component={HealthRecordScreen}
options={{ title: '健康档案' }}
/>
</Stack.Navigator>
)
}
export default ProfileNavigator
```
### 步骤 5:创建根导航器
创建 `src/navigation/RootNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { useAuthStore } from '../stores/useAuthStore'
import type { RootStackParamList } from './types'
import LoginScreen from '../screens/auth/LoginScreen'
import MainTabNavigator from './MainTabNavigator'
const Stack = createNativeStackNavigator<RootStackParamList>()
const RootNavigator = () => {
const { isLoggedIn } = useAuthStore()
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
{!isLoggedIn ? (
<Stack.Screen name="Auth" component={LoginScreen} />
) : (
<Stack.Screen name="Main" component={MainTabNavigator} />
)}
</Stack.Navigator>
)
}
export default RootNavigator
```
### 步骤 6:更新 App.tsx
```typescript
import React from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { PaperProvider } from 'react-native-paper'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import RootNavigator from './src/navigation/RootNavigator'
import { theme } from './src/theme'
const App = () => {
return (
<SafeAreaProvider>
<PaperProvider theme={theme}>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
</PaperProvider>
</SafeAreaProvider>
)
}
export default App
```
---
## 导航结构
```
RootNavigator
├── LoginScreen(未登录)
└── MainTabNavigator(已登录)
├── HomeTab
│ └── Home
├── ChatTab
│ ├── ChatList
│ └── ChatDetail
├── ConstitutionTab
│ ├── ConstitutionHome
│ ├── ConstitutionQuestions
│ └── ConstitutionResult
└── ProfileTab
├── ProfileHome
└── HealthRecord
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/navigation/types.ts` | 导航类型定义 |
| `src/stores/useAuthStore.ts` | 认证状态 |
| `src/navigation/RootNavigator.tsx` | 根导航 |
| `src/navigation/MainTabNavigator.tsx` | Tab 导航 |
| `src/navigation/HomeNavigator.tsx` | 首页导航 |
| `src/navigation/ChatNavigator.tsx` | 对话导航 |
| `src/navigation/ConstitutionNavigator.tsx` | 体质导航 |
| `src/navigation/ProfileNavigator.tsx` | 个人导航 |
---
## 验收标准
- [ ] 导航结构配置正确
- [ ] Tab 导航显示正常
- [ ] Stack 导航跳转正常
- [ ] 登录状态切换导航正常
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `02-APP原型开发/03-登录页面.md`

295
TODOS/02-APP原型开发/03-登录页面.md

@ -0,0 +1,295 @@
# 03-登录页面(原型)
## 目标
实现 APP 端登录页面原型,使用模拟数据验证登录。
---
## UI 设计参考
> 参考设计稿:`files/ui/登录页.png`
### 页面布局
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色渐变背景 (`#10B981 → #2EC4B6`) + 医疗插图 |
| Logo | "AI健康助手" 标题(白色 32px)+ slogan |
| 表单卡片 | 白色背景,圆角 16px |
| 输入框 | 圆角 12px,左侧带图标 |
| 主按钮 | 绿色 `#10B981`,圆角 24px,高度 48px |
---
## 前置要求
- 导航配置完成
- 模拟数据服务已创建
---
## 实施步骤
### 步骤 1:创建登录页面
创建 `src/screens/auth/LoginScreen.tsx`
```typescript
import React, { useState } from 'react'
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
Alert,
Image,
} from 'react-native'
import { TextInput, Button } from 'react-native-paper'
import { useAuthStore } from '../../stores/useAuthStore'
import { mockLogin } from '../../mock/user'
const LoginScreen = () => {
const { login } = useAuthStore()
const [phone, setPhone] = useState('13800138000') // 预填测试账号
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [countdown, setCountdown] = useState(0)
// 模拟发送验证码
const handleSendCode = () => {
if (!phone.trim() || phone.length !== 11) {
Alert.alert('提示', '请输入正确的手机号')
return
}
// 开始倒计时
setCountdown(60)
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
Alert.alert('提示', '验证码已发送,测试验证码为:123456')
}
const handleLogin = async () => {
if (!phone.trim()) {
Alert.alert('提示', '请输入手机号')
return
}
if (!code.trim()) {
Alert.alert('提示', '请输入验证码')
return
}
setLoading(true)
try {
const user = await mockLogin(phone, code)
if (user) {
login(user)
} else {
Alert.alert('登录失败', '验证码错误,请输入:123456')
}
} finally {
setLoading(false)
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{/* 顶部背景 */}
<View style={styles.header}>
<Text style={styles.title}>AI健康助手</Text>
<Text style={styles.subtitle}>您的智能健康管家</Text>
</View>
{/* 登录表单 */}
<View style={styles.form}>
<Text style={styles.formTitle}>手机号登录</Text>
<TextInput
label="手机号"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
maxLength={11}
style={styles.input}
mode="outlined"
outlineColor="#E5E7EB"
activeOutlineColor="#10B981"
left={<TextInput.Icon icon="phone" color="#9CA3AF" />}
/>
<View style={styles.codeRow}>
<TextInput
label="验证码"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
maxLength={6}
style={[styles.input, styles.codeInput]}
mode="outlined"
outlineColor="#E5E7EB"
activeOutlineColor="#10B981"
left={<TextInput.Icon icon="shield-check" color="#9CA3AF" />}
/>
<Button
mode="outlined"
onPress={handleSendCode}
disabled={countdown > 0}
style={styles.codeButton}
labelStyle={styles.codeButtonLabel}
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</View>
<Button
mode="contained"
onPress={handleLogin}
loading={loading}
disabled={loading || !phone.trim() || !code.trim()}
style={styles.loginButton}
contentStyle={styles.loginButtonContent}
buttonColor="#10B981"
>
登录
</Button>
<Text style={styles.hint}>
测试账号:13800138000,验证码:123456
</Text>
<Text style={styles.agreement}>
登录即表示同意《用户协议》和《隐私政策》
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#10B981',
},
scrollContent: {
flexGrow: 1,
},
header: {
alignItems: 'center',
paddingTop: 80,
paddingBottom: 40,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: 'rgba(255,255,255,0.8)',
},
form: {
flex: 1,
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 24,
},
formTitle: {
fontSize: 20,
fontWeight: '600',
color: '#1F2937',
marginBottom: 24,
textAlign: 'center',
},
input: {
marginBottom: 16,
backgroundColor: '#fff',
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
codeInput: {
flex: 1,
},
codeButton: {
marginBottom: 16,
borderColor: '#10B981',
},
codeButtonLabel: {
color: '#10B981',
},
loginButton: {
marginTop: 8,
borderRadius: 24,
},
loginButtonContent: {
paddingVertical: 8,
},
hint: {
marginTop: 16,
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
},
agreement: {
marginTop: 24,
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
},
})
export default LoginScreen
```
---
## 模拟数据说明
登录使用 `src/mock/user.ts` 中的模拟数据:
- 测试手机号:`13800138000`
- 测试验证码:`123456`
---
## 验收标准
- [ ] 登录页面 UI 正常显示
- [ ] 验证码倒计时正常
- [ ] 正确验证码可登录成功
- [ ] 错误验证码提示错误
- [ ] 登录成功后跳转到首页
---
## 预计耗时
20-25 分钟
---
## 下一步
完成后进入 `02-APP原型开发/04-首页.md`

417
TODOS/02-APP原型开发/04-首页.md

@ -0,0 +1,417 @@
# 04-首页(原型)
## 目标
实现 APP 首页原型,展示用户体质信息、快捷入口和健康提示。
---
## UI 设计参考
> 参考设计稿:`files/ui/首页.png`
### 页面布局
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色背景 `#10B981`,用户问候语 |
| 体质卡片 | 白色卡片,显示当前体质类型和简介 |
| 快捷入口 | 4个功能入口(AI问诊、体质测试、健康档案、商城) |
| 健康提示 | 每日健康小贴士 |
| 推荐产品 | 根据体质推荐的保健品(横向滚动) |
---
## 前置要求
- 导航配置完成
- 模拟数据服务已创建
- 登录页面完成
---
## 实施步骤
### 步骤 1:创建体质状态 Store
创建 `src/stores/useConstitutionStore.ts`
```typescript
import { create } from 'zustand'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { ConstitutionResult } from '../types'
interface ConstitutionState {
result: ConstitutionResult | null
setResult: (result: ConstitutionResult) => void
clearResult: () => void
}
export const useConstitutionStore = create<ConstitutionState>((set) => ({
result: null,
setResult: (result) => {
AsyncStorage.setItem('constitution_result', JSON.stringify(result))
set({ result })
},
clearResult: () => {
AsyncStorage.removeItem('constitution_result')
set({ result: null })
},
}))
```
### 步骤 2:创建首页
创建 `src/screens/home/HomeScreen.tsx`
```typescript
import React from 'react'
import {
View,
ScrollView,
StyleSheet,
TouchableOpacity,
Linking,
} from 'react-native'
import { Text, Card, Avatar } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import { useAuthStore } from '../../stores/useAuthStore'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionNames, constitutionDescriptions } from '../../mock/constitution'
import { getProductsByConstitution, mockProducts } from '../../mock/products'
const HomeScreen = () => {
const navigation = useNavigation<any>()
const { user } = useAuthStore()
const { result } = useConstitutionStore()
// 获取当前时间段问候语
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 12) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
}
// 获取推荐产品
const recommendedProducts = result
? getProductsByConstitution(result.primaryType)
: mockProducts.slice(0, 4)
// 快捷入口数据
const quickActions = [
{ icon: 'chat-processing', label: 'AI问诊', color: '#3B82F6', onPress: () => navigation.navigate('ChatTab') },
{ icon: 'heart-pulse', label: '体质测试', color: '#10B981', onPress: () => navigation.navigate('ConstitutionTab') },
{ icon: 'file-document', label: '健康档案', color: '#8B5CF6', onPress: () => navigation.navigate('ProfileTab', { screen: 'HealthRecord' }) },
{ icon: 'store', label: '健康商城', color: '#F59E0B', onPress: () => Linking.openURL('https://mall.example.com') },
]
return (
<ScrollView style={styles.container}>
{/* 顶部问候 */}
<View style={styles.header}>
<View style={styles.greeting}>
<Text style={styles.greetingText}>
{getGreeting()},{user?.nickname || '用户'}
</Text>
<Text style={styles.greetingSubtext}>今天也要保持健康哦~</Text>
</View>
<Avatar.Text
size={48}
label={user?.nickname?.charAt(0) || 'U'}
style={styles.avatar}
/>
</View>
{/* 体质卡片 */}
<Card style={styles.constitutionCard}>
<Card.Content>
{result ? (
<>
<View style={styles.constitutionHeader}>
<Text style={styles.constitutionLabel}>我的体质</Text>
<TouchableOpacity
onPress={() => navigation.navigate('ConstitutionTab', { screen: 'ConstitutionResult' })}
>
<Text style={styles.viewMore}>查看详情 →</Text>
</TouchableOpacity>
</View>
<View style={styles.constitutionBody}>
<View style={styles.constitutionType}>
<Icon name="heart-pulse" size={32} color="#10B981" />
<Text style={styles.constitutionName}>
{constitutionNames[result.primaryType]}
</Text>
</View>
<Text style={styles.constitutionDesc} numberOfLines={2}>
{constitutionDescriptions[result.primaryType].description}
</Text>
</View>
</>
) : (
<TouchableOpacity
style={styles.noConstitution}
onPress={() => navigation.navigate('ConstitutionTab')}
>
<Icon name="clipboard-text-outline" size={48} color="#9CA3AF" />
<Text style={styles.noConstitutionText}>还未进行体质测试</Text>
<Text style={styles.noConstitutionHint}>点击开始测试,了解您的体质类型</Text>
</TouchableOpacity>
)}
</Card.Content>
</Card>
{/* 快捷入口 */}
<View style={styles.quickActions}>
{quickActions.map((action, index) => (
<TouchableOpacity
key={index}
style={styles.quickAction}
onPress={action.onPress}
>
<View style={[styles.quickActionIcon, { backgroundColor: action.color + '20' }]}>
<Icon name={action.icon} size={24} color={action.color} />
</View>
<Text style={styles.quickActionLabel}>{action.label}</Text>
</TouchableOpacity>
))}
</View>
{/* 健康提示 */}
<Card style={styles.tipCard}>
<Card.Content style={styles.tipContent}>
<Icon name="lightbulb-outline" size={24} color="#F59E0B" />
<View style={styles.tipTextContainer}>
<Text style={styles.tipTitle}>今日健康提示</Text>
<Text style={styles.tipText}>
{result
? constitutionDescriptions[result.primaryType].suggestions[0]
: '保持良好的作息习惯,每天喝足8杯水,适当运动有益身心健康。'
}
</Text>
</View>
</Card.Content>
</Card>
{/* 推荐产品 */}
<View style={styles.productsSection}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{result ? '适合您的调养产品' : '热门保健品'}
</Text>
<TouchableOpacity onPress={() => Linking.openURL('https://mall.example.com')}>
<Text style={styles.viewMore}>查看更多 →</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{recommendedProducts.map((product) => (
<TouchableOpacity
key={product.id}
style={styles.productCard}
onPress={() => Linking.openURL(product.mallUrl)}
>
<View style={styles.productImage}>
<Icon name="pill" size={32} color="#10B981" />
</View>
<Text style={styles.productName} numberOfLines={1}>{product.name}</Text>
<Text style={styles.productPrice}>¥{product.price}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F3F4F6',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
backgroundColor: '#10B981',
},
greeting: {},
greetingText: {
fontSize: 20,
fontWeight: '600',
color: '#fff',
},
greetingSubtext: {
fontSize: 14,
color: 'rgba(255,255,255,0.8)',
marginTop: 4,
},
avatar: {
backgroundColor: 'rgba(255,255,255,0.2)',
},
constitutionCard: {
margin: 16,
marginTop: -20,
borderRadius: 16,
elevation: 4,
},
constitutionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
constitutionLabel: {
fontSize: 14,
color: '#6B7280',
},
viewMore: {
fontSize: 14,
color: '#10B981',
},
constitutionBody: {},
constitutionType: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
constitutionName: {
fontSize: 24,
fontWeight: 'bold',
color: '#1F2937',
marginLeft: 8,
},
constitutionDesc: {
fontSize: 14,
color: '#6B7280',
lineHeight: 20,
},
noConstitution: {
alignItems: 'center',
padding: 20,
},
noConstitutionText: {
fontSize: 16,
color: '#6B7280',
marginTop: 12,
},
noConstitutionHint: {
fontSize: 14,
color: '#9CA3AF',
marginTop: 4,
},
quickActions: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 16,
marginBottom: 16,
},
quickAction: {
alignItems: 'center',
},
quickActionIcon: {
width: 56,
height: 56,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
quickActionLabel: {
fontSize: 12,
color: '#4B5563',
},
tipCard: {
marginHorizontal: 16,
marginBottom: 16,
borderRadius: 12,
backgroundColor: '#FFFBEB',
},
tipContent: {
flexDirection: 'row',
alignItems: 'flex-start',
},
tipTextContainer: {
flex: 1,
marginLeft: 12,
},
tipTitle: {
fontSize: 14,
fontWeight: '600',
color: '#92400E',
marginBottom: 4,
},
tipText: {
fontSize: 13,
color: '#B45309',
lineHeight: 18,
},
productsSection: {
paddingHorizontal: 16,
marginBottom: 24,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
},
productCard: {
width: 120,
backgroundColor: '#fff',
borderRadius: 12,
padding: 12,
marginRight: 12,
alignItems: 'center',
},
productImage: {
width: 64,
height: 64,
backgroundColor: '#ECFDF5',
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
productName: {
fontSize: 13,
color: '#1F2937',
marginBottom: 4,
},
productPrice: {
fontSize: 14,
fontWeight: '600',
color: '#EF4444',
},
})
export default HomeScreen
```
---
## 验收标准
- [ ] 首页 UI 正常显示
- [ ] 用户问候语显示正确
- [ ] 体质卡片显示(有/无体质结果两种状态)
- [ ] 快捷入口点击跳转正常
- [ ] 推荐产品显示正常
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `02-APP原型开发/05-体质辨识页面.md`

466
TODOS/02-APP原型开发/05-体质辨识页面.md

@ -0,0 +1,466 @@
# 05-体质辨识页面(原型)
## 目标
实现 APP 端中医体质辨识问卷和结果展示,使用本地模拟数据计算结果。
---
## UI 设计参考
> 参考设计稿:`files/ui/体质页.png`、`files/ui/体质检测.png`、`files/ui/体质分析.png`
---
## 页面组成
1. **体质首页** - 介绍页面,引导用户开始测试
2. **问卷页面** - 60道题目,逐题作答
3. **结果页面** - 显示体质类型、雷达图、调养建议
---
## 前置要求
- 导航配置完成
- 模拟数据服务已创建(`src/mock/constitution.ts`)
---
## 实施步骤
### 步骤 1:体质首页
创建 `src/screens/constitution/ConstitutionHomeScreen.tsx`
```typescript
import React from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { Text, Card, Button } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionNames, constitutionDescriptions } from '../../mock/constitution'
const ConstitutionHomeScreen = () => {
const navigation = useNavigation<any>()
const { result } = useConstitutionStore()
const steps = [
{ icon: 'clipboard-text', title: '回答问卷', desc: '60道题目,约10分钟' },
{ icon: 'calculator', title: '智能分析', desc: '根据答案计算体质' },
{ icon: 'file-document', title: '获取报告', desc: '体质类型和调养建议' },
]
return (
<ScrollView style={styles.container}>
{/* 已有结果时显示 */}
{result && (
<Card style={styles.resultCard}>
<Card.Content>
<View style={styles.resultHeader}>
<Icon name="check-circle" size={24} color="#10B981" />
<Text style={styles.resultTitle}>您已完成体质测评</Text>
</View>
<View style={styles.resultBody}>
<Text style={styles.resultType}>
{constitutionNames[result.primaryType]}
</Text>
<Text style={styles.resultDesc}>
{constitutionDescriptions[result.primaryType].description}
</Text>
</View>
<Button
mode="contained"
onPress={() => navigation.navigate('ConstitutionResult')}
buttonColor="#10B981"
style={styles.resultButton}
>
查看详细报告
</Button>
</Card.Content>
</Card>
)}
{/* 介绍卡片 */}
<Card style={styles.introCard}>
<Card.Content>
<Text style={styles.introTitle}>中医体质自测</Text>
<Text style={styles.introDesc}>
中医体质辨识是以中医理论为指导,根据人体生理特点分为9种基本体质类型。
了解自己的体质类型,有助于选择适合的养生方法。
</Text>
</Card.Content>
</Card>
{/* 步骤说明 */}
<View style={styles.steps}>
{steps.map((step, index) => (
<View key={index} style={styles.stepItem}>
<View style={styles.stepIcon}>
<Icon name={step.icon} size={24} color="#10B981" />
</View>
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>{step.title}</Text>
<Text style={styles.stepDesc}>{step.desc}</Text>
</View>
{index < steps.length - 1 && <View style={styles.stepLine} />}
</View>
))}
</View>
{/* 开始按钮 */}
<Button
mode="contained"
onPress={() => navigation.navigate('ConstitutionQuestions')}
buttonColor="#10B981"
style={styles.startButton}
contentStyle={styles.startButtonContent}
>
{result ? '重新测评' : '开始测评'}
</Button>
<Text style={styles.note}>
建议每3-6个月重新测评一次,以跟踪体质变化
</Text>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6', padding: 16 },
resultCard: { marginBottom: 16, borderRadius: 12, borderLeftWidth: 4, borderLeftColor: '#10B981' },
resultHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
resultTitle: { fontSize: 16, fontWeight: '600', marginLeft: 8, color: '#10B981' },
resultBody: { marginBottom: 12 },
resultType: { fontSize: 24, fontWeight: 'bold', color: '#1F2937' },
resultDesc: { fontSize: 14, color: '#6B7280', marginTop: 4 },
resultButton: { borderRadius: 8 },
introCard: { marginBottom: 16, borderRadius: 12 },
introTitle: { fontSize: 20, fontWeight: 'bold', color: '#1F2937', marginBottom: 8 },
introDesc: { fontSize: 14, color: '#6B7280', lineHeight: 22 },
steps: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 16 },
stepItem: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 16 },
stepIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#ECFDF5', justifyContent: 'center', alignItems: 'center' },
stepContent: { flex: 1, marginLeft: 12 },
stepTitle: { fontSize: 16, fontWeight: '600', color: '#1F2937' },
stepDesc: { fontSize: 13, color: '#6B7280', marginTop: 2 },
stepLine: { position: 'absolute', left: 24, top: 48, width: 1, height: 16, backgroundColor: '#E5E7EB' },
startButton: { borderRadius: 24, marginBottom: 12 },
startButtonContent: { paddingVertical: 8 },
note: { fontSize: 12, color: '#9CA3AF', textAlign: 'center' },
})
export default ConstitutionHomeScreen
```
### 步骤 2:问卷页面
创建 `src/screens/constitution/ConstitutionQuestionsScreen.tsx`
```typescript
import React, { useState } from 'react'
import { View, ScrollView, StyleSheet, Alert } from 'react-native'
import { Text, Button, ProgressBar, Card } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionQuestions, calculateConstitution } from '../../mock/constitution'
const ConstitutionQuestionsScreen = () => {
const navigation = useNavigation<any>()
const { setResult } = useConstitutionStore()
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<number, number>>({})
const questions = constitutionQuestions
const currentQuestion = questions[currentIndex]
const progress = (currentIndex + 1) / questions.length
const isLastQuestion = currentIndex === questions.length - 1
const selectOption = (value: number) => {
setAnswers({ ...answers, [currentQuestion.id]: value })
}
const handleNext = () => {
if (!answers[currentQuestion.id]) {
Alert.alert('提示', '请选择一个选项')
return
}
if (currentIndex < questions.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const handleSubmit = () => {
if (Object.keys(answers).length < questions.length) {
Alert.alert('提示', '请完成所有题目')
return
}
// 本地计算结果
const result = calculateConstitution(answers)
setResult(result)
navigation.navigate('ConstitutionResult')
}
return (
<View style={styles.container}>
{/* 进度条 */}
<View style={styles.progressContainer}>
<Text style={styles.progressText}>
第 {currentIndex + 1} 题 / 共 {questions.length} 题
</Text>
<ProgressBar progress={progress} color="#10B981" style={styles.progressBar} />
</View>
{/* 问题卡片 */}
<ScrollView style={styles.content}>
<Card style={styles.questionCard}>
<Card.Content>
<Text style={styles.questionText}>{currentQuestion.question}</Text>
<View style={styles.options}>
{currentQuestion.options.map((option) => (
<Button
key={option.value}
mode={answers[currentQuestion.id] === option.value ? 'contained' : 'outlined'}
onPress={() => selectOption(option.value)}
style={styles.optionButton}
buttonColor={answers[currentQuestion.id] === option.value ? '#10B981' : undefined}
>
{option.label}
</Button>
))}
</View>
</Card.Content>
</Card>
</ScrollView>
{/* 导航按钮 */}
<View style={styles.navButtons}>
<Button
mode="outlined"
onPress={handlePrev}
disabled={currentIndex === 0}
style={styles.navButton}
>
上一题
</Button>
{isLastQuestion ? (
<Button
mode="contained"
onPress={handleSubmit}
buttonColor="#10B981"
style={styles.navButton}
>
提交
</Button>
) : (
<Button
mode="contained"
onPress={handleNext}
buttonColor="#10B981"
style={styles.navButton}
>
下一题
</Button>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6' },
progressContainer: { padding: 16, backgroundColor: '#fff' },
progressText: { textAlign: 'center', marginBottom: 8, color: '#6B7280' },
progressBar: { height: 6, borderRadius: 3 },
content: { flex: 1, padding: 16 },
questionCard: { borderRadius: 12 },
questionText: { fontSize: 18, lineHeight: 28, color: '#1F2937', marginBottom: 20 },
options: { gap: 12 },
optionButton: { marginBottom: 8, borderRadius: 8 },
navButtons: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#E5E7EB' },
navButton: { flex: 1, marginHorizontal: 8, borderRadius: 8 },
})
export default ConstitutionQuestionsScreen
```
### 步骤 3:结果页面
创建 `src/screens/constitution/ConstitutionResultScreen.tsx`
```typescript
import React from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { Text, Card, Chip, Button } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionNames, constitutionDescriptions } from '../../mock/constitution'
import { getProductsByConstitution } from '../../mock/products'
const ConstitutionResultScreen = () => {
const navigation = useNavigation<any>()
const { result } = useConstitutionStore()
if (!result) {
return (
<View style={styles.emptyContainer}>
<Text>暂无测评结果</Text>
<Button mode="contained" onPress={() => navigation.navigate('ConstitutionQuestions')}>
开始测评
</Button>
</View>
)
}
const info = constitutionDescriptions[result.primaryType]
const products = getProductsByConstitution(result.primaryType)
// 计算所有体质得分用于显示
const allScores = Object.entries(result.scores)
.map(([type, score]) => ({
type,
name: constitutionNames[type as keyof typeof constitutionNames],
score,
}))
.sort((a, b) => b.score - a.score)
return (
<ScrollView style={styles.container}>
{/* 主体质卡片 */}
<Card style={styles.primaryCard}>
<Card.Content style={styles.primaryContent}>
<Icon name="heart-pulse" size={48} color="#10B981" />
<Text style={styles.primaryType}>{constitutionNames[result.primaryType]}</Text>
<Text style={styles.primaryScore}>{result.scores[result.primaryType]}分</Text>
<Text style={styles.primaryDesc}>{info.description}</Text>
</Card.Content>
</Card>
{/* 体质得分 */}
<Card style={styles.card}>
<Card.Title title="体质得分分布" />
<Card.Content>
{allScores.map((item) => (
<View key={item.type} style={styles.scoreItem}>
<Text style={styles.scoreName}>{item.name}</Text>
<View style={styles.scoreBar}>
<View style={[styles.scoreBarFill, { width: `${item.score}%` }]} />
</View>
<Text style={styles.scoreValue}>{item.score}</Text>
</View>
))}
</Card.Content>
</Card>
{/* 体质特征 */}
<Card style={styles.card}>
<Card.Title title="体质特征" />
<Card.Content>
<View style={styles.tagList}>
{info.features.map((feature, index) => (
<Chip key={index} style={styles.tag}>{feature}</Chip>
))}
</View>
</Card.Content>
</Card>
{/* 调养建议 */}
<Card style={styles.card}>
<Card.Title title="调养建议" />
<Card.Content>
{info.suggestions.map((suggestion, index) => (
<View key={index} style={styles.suggestionItem}>
<Icon name="check-circle" size={20} color="#10B981" />
<Text style={styles.suggestionText}>{suggestion}</Text>
</View>
))}
</Card.Content>
</Card>
{/* 推荐产品 */}
<Card style={styles.card}>
<Card.Title title="推荐调养产品" />
<Card.Content>
{products.map((product) => (
<View key={product.id} style={styles.productItem}>
<Text style={styles.productName}>{product.name}</Text>
<Text style={styles.productPrice}>¥{product.price}</Text>
</View>
))}
</Card.Content>
</Card>
{/* 操作按钮 */}
<View style={styles.actions}>
<Button
mode="contained"
onPress={() => navigation.navigate('ChatTab')}
buttonColor="#10B981"
style={styles.actionButton}
>
咨询AI助手
</Button>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6', padding: 16 },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
primaryCard: { borderRadius: 16, marginBottom: 16 },
primaryContent: { alignItems: 'center', paddingVertical: 24 },
primaryType: { fontSize: 28, fontWeight: 'bold', color: '#1F2937', marginTop: 12 },
primaryScore: { fontSize: 18, color: '#10B981', marginTop: 4 },
primaryDesc: { fontSize: 14, color: '#6B7280', marginTop: 12, textAlign: 'center', lineHeight: 22 },
card: { borderRadius: 12, marginBottom: 16 },
scoreItem: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
scoreName: { width: 60, fontSize: 13 },
scoreBar: { flex: 1, height: 8, backgroundColor: '#E5E7EB', borderRadius: 4, marginHorizontal: 8 },
scoreBarFill: { height: '100%', backgroundColor: '#10B981', borderRadius: 4 },
scoreValue: { width: 30, textAlign: 'right', fontSize: 13 },
tagList: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tag: { backgroundColor: '#ECFDF5' },
suggestionItem: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 12 },
suggestionText: { flex: 1, marginLeft: 8, fontSize: 14, color: '#4B5563', lineHeight: 20 },
productItem: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#E5E7EB' },
productName: { fontSize: 14, color: '#1F2937' },
productPrice: { fontSize: 14, color: '#EF4444', fontWeight: '600' },
actions: { padding: 16 },
actionButton: { borderRadius: 24 },
})
export default ConstitutionResultScreen
```
---
## 验收标准
- [ ] 体质首页正常显示
- [ ] 问卷60题可完整答题
- [ ] 进度条显示正确
- [ ] 提交后本地计算结果
- [ ] 结果页显示体质类型和建议
- [ ] 体质得分分布正确
---
## 预计耗时
40-50 分钟
---
## 下一步
完成后进入 `02-APP原型开发/06-AI对话页面.md`

387
TODOS/02-APP原型开发/06-AI对话页面.md

@ -0,0 +1,387 @@
# 06-AI对话页面(原型)
## 目标
实现 APP 端 AI 健康问诊对话功能,使用模拟数据模拟多轮对话效果。
---
## UI 设计参考
> 参考设计稿:`files/ui/问答页.png`、`files/ui/问答对话.png`
---
## 前置要求
- 导航配置完成
- 模拟数据服务已创建(`src/mock/chat.ts`)
---
## 实施步骤
### 步骤 1:创建对话状态 Store
创建 `src/stores/useChatStore.ts`
```typescript
import { create } from 'zustand'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Conversation, Message } from '../types'
interface ChatState {
conversations: Conversation[]
addConversation: (conv: Conversation) => void
deleteConversation: (id: string) => void
addMessage: (convId: string, message: Message) => void
}
export const useChatStore = create<ChatState>((set, get) => ({
conversations: [],
addConversation: (conv) => {
const updated = [conv, ...get().conversations]
AsyncStorage.setItem('conversations', JSON.stringify(updated))
set({ conversations: updated })
},
deleteConversation: (id) => {
const updated = get().conversations.filter((c) => c.id !== id)
AsyncStorage.setItem('conversations', JSON.stringify(updated))
set({ conversations: updated })
},
addMessage: (convId, message) => {
const updated = get().conversations.map((c) => {
if (c.id === convId) {
return {
...c,
messages: [...c.messages, message],
updatedAt: new Date().toISOString(),
}
}
return c
})
AsyncStorage.setItem('conversations', JSON.stringify(updated))
set({ conversations: updated })
},
}))
```
### 步骤 2:对话列表页面
创建 `src/screens/chat/ChatListScreen.tsx`
```typescript
import React from 'react'
import { View, FlatList, StyleSheet, TouchableOpacity, Alert } from 'react-native'
import { Text, FAB, Card, IconButton } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import dayjs from 'dayjs'
import { useChatStore } from '../../stores/useChatStore'
import type { ChatNavigationProp } from '../../navigation/types'
const ChatListScreen = () => {
const navigation = useNavigation<ChatNavigationProp>()
const { conversations, addConversation, deleteConversation } = useChatStore()
const handleCreate = () => {
const newConv = {
id: Date.now().toString(),
title: '新对话',
messages: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
addConversation(newConv)
navigation.navigate('ChatDetail', { id: newConv.id })
}
const handleDelete = (id: string) => {
Alert.alert('确认删除', '确定要删除这个对话吗?', [
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: () => deleteConversation(id) },
])
}
const renderItem = ({ item }: { item: typeof conversations[0] }) => (
<TouchableOpacity onPress={() => navigation.navigate('ChatDetail', { id: item.id })}>
<Card style={styles.card}>
<Card.Content style={styles.cardContent}>
<View style={styles.cardIcon}>
<Icon name="chat-processing" size={24} color="#10B981" />
</View>
<View style={styles.cardInfo}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardTime}>
{dayjs(item.updatedAt).format('MM-DD HH:mm')}
</Text>
</View>
<IconButton
icon="delete-outline"
size={20}
onPress={() => handleDelete(item.id)}
/>
</Card.Content>
</Card>
</TouchableOpacity>
)
return (
<View style={styles.container}>
{conversations.length === 0 ? (
<View style={styles.emptyContainer}>
<Icon name="chat-outline" size={64} color="#D1D5DB" />
<Text style={styles.emptyText}>暂无对话记录</Text>
<Text style={styles.emptySubtext}>点击下方按钮开始咨询</Text>
</View>
) : (
<FlatList
data={conversations}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
)}
<FAB
icon="plus"
style={styles.fab}
onPress={handleCreate}
label="新建对话"
color="#fff"
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6' },
list: { padding: 16 },
card: { marginBottom: 12, borderRadius: 12 },
cardContent: { flexDirection: 'row', alignItems: 'center' },
cardIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#ECFDF5', justifyContent: 'center', alignItems: 'center' },
cardInfo: { flex: 1, marginLeft: 12 },
cardTitle: { fontSize: 16, fontWeight: '500', color: '#1F2937' },
cardTime: { fontSize: 12, color: '#9CA3AF', marginTop: 4 },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
emptyText: { fontSize: 16, color: '#6B7280', marginTop: 16 },
emptySubtext: { fontSize: 14, color: '#9CA3AF', marginTop: 4 },
fab: { position: 'absolute', right: 16, bottom: 16, backgroundColor: '#10B981' },
})
export default ChatListScreen
```
### 步骤 3:对话详情页面
创建 `src/screens/chat/ChatDetailScreen.tsx`
```typescript
import React, { useState, useRef } from 'react'
import {
View,
FlatList,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native'
import { Text, TextInput, IconButton, Avatar } from 'react-native-paper'
import { useRoute } from '@react-navigation/native'
import { useChatStore } from '../../stores/useChatStore'
import { useAuthStore } from '../../stores/useAuthStore'
import { mockAIReply } from '../../mock/chat'
import type { ChatDetailRouteProp } from '../../navigation/types'
import type { Message } from '../../types'
const ChatDetailScreen = () => {
const route = useRoute<ChatDetailRouteProp>()
const { id } = route.params
const { conversations, addMessage } = useChatStore()
const { user } = useAuthStore()
const flatListRef = useRef<FlatList>(null)
const conversation = conversations.find((c) => c.id === id)
const messages = conversation?.messages || []
const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false)
const handleSend = async () => {
const content = inputText.trim()
if (!content || sending) return
// 添加用户消息
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content,
createdAt: new Date().toISOString(),
}
addMessage(id, userMessage)
setInputText('')
// 模拟AI回复
setSending(true)
try {
const reply = await mockAIReply(content)
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: reply,
createdAt: new Date().toISOString(),
}
addMessage(id, assistantMessage)
} finally {
setSending(false)
}
}
const renderMessage = ({ item }: { item: Message }) => {
const isUser = item.role === 'user'
return (
<View style={[styles.messageRow, isUser && styles.messageRowUser]}>
{!isUser && (
<Avatar.Icon size={36} icon="robot" style={styles.avatarAI} />
)}
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.assistantBubble]}>
<Text style={isUser ? styles.userText : styles.assistantText}>
{item.content}
</Text>
</View>
{isUser && (
<Avatar.Text
size={36}
label={user?.nickname?.charAt(0) || 'U'}
style={styles.avatarUser}
/>
)}
</View>
)
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
{/* 欢迎消息 */}
{messages.length === 0 && (
<View style={styles.welcomeContainer}>
<Avatar.Icon size={64} icon="robot" style={styles.welcomeAvatar} />
<Text style={styles.welcomeTitle}>AI健康助手</Text>
<Text style={styles.welcomeText}>
您好!我是AI健康助手,可以为您提供健康咨询和建议。
{'\n'}请描述您的症状或健康问题。
</Text>
</View>
)}
{/* 消息列表 */}
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.messageList}
onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
/>
{/* 输入中提示 */}
{sending && (
<View style={styles.typingIndicator}>
<Text style={styles.typingText}>AI 正在思考...</Text>
</View>
)}
{/* 输入区域 */}
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="请描述您的健康问题..."
multiline
maxLength={500}
disabled={sending}
/>
<IconButton
icon="send"
size={24}
iconColor="#fff"
style={styles.sendButton}
disabled={!inputText.trim() || sending}
onPress={handleSend}
/>
</View>
{/* 免责声明 */}
<View style={styles.disclaimer}>
<Text style={styles.disclaimerText}>
AI 建议仅供参考,不构成医疗诊断,如有需要请就医
</Text>
</View>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6' },
welcomeContainer: { alignItems: 'center', padding: 32 },
welcomeAvatar: { backgroundColor: '#3B82F6' },
welcomeTitle: { fontSize: 20, fontWeight: '600', marginTop: 16, color: '#1F2937' },
welcomeText: { fontSize: 14, color: '#6B7280', textAlign: 'center', marginTop: 8, lineHeight: 22 },
messageList: { padding: 16 },
messageRow: { flexDirection: 'row', marginBottom: 16, alignItems: 'flex-end' },
messageRowUser: { flexDirection: 'row-reverse' },
avatarAI: { backgroundColor: '#3B82F6', marginRight: 8 },
avatarUser: { backgroundColor: '#10B981', marginLeft: 8 },
messageBubble: { maxWidth: '70%', padding: 12, borderRadius: 16 },
userBubble: { backgroundColor: '#10B981', borderBottomRightRadius: 4 },
assistantBubble: { backgroundColor: '#fff', borderBottomLeftRadius: 4 },
userText: { color: '#fff', fontSize: 14, lineHeight: 20 },
assistantText: { color: '#1F2937', fontSize: 14, lineHeight: 20 },
typingIndicator: { paddingHorizontal: 16, paddingVertical: 8 },
typingText: { color: '#9CA3AF', fontSize: 13 },
inputContainer: { flexDirection: 'row', alignItems: 'center', padding: 8, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#E5E7EB' },
input: { flex: 1, maxHeight: 100, backgroundColor: '#F3F4F6', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8 },
sendButton: { backgroundColor: '#10B981', marginLeft: 8 },
disclaimer: { padding: 8, backgroundColor: '#FEF3C7', alignItems: 'center' },
disclaimerText: { fontSize: 11, color: '#92400E' },
})
export default ChatDetailScreen
```
---
## 模拟数据说明
使用 `src/mock/chat.ts` 中的 `mockAIReply` 函数:
- 根据关键词匹配预设回答
- 支持:疲劳、失眠、关节痛等常见问题
- 回答格式包含:情况分析、建议、用药参考、产品推荐
---
## 验收标准
- [ ] 对话列表正常显示
- [ ] 新建对话正常
- [ ] 删除对话正常
- [ ] 消息发送和模拟回复正常
- [ ] 消息气泡样式正确
- [ ] 免责声明显示
---
## 预计耗时
35-45 分钟
---
## 下一步
完成后进入 `02-APP原型开发/07-个人中心页面.md`

383
TODOS/02-APP原型开发/07-个人中心页面.md

@ -0,0 +1,383 @@
# 07-个人中心页面(原型)
## 目标
实现 APP 端个人中心和健康档案管理页面原型。
---
## UI 设计参考
> 参考设计稿:`files/ui/我的.png`
---
## 前置要求
- 导航配置完成
- 认证状态 Store 已创建
- 体质状态 Store 已创建
---
## 实施步骤
### 步骤 1:个人中心页面
创建 `src/screens/profile/ProfileHomeScreen.tsx`
```typescript
import React from 'react'
import { View, ScrollView, StyleSheet, Alert, Linking } from 'react-native'
import { Text, Avatar, Card, List, Button, Divider } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import { useAuthStore } from '../../stores/useAuthStore'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionNames } from '../../mock/constitution'
const ProfileHomeScreen = () => {
const navigation = useNavigation<any>()
const { user, logout } = useAuthStore()
const { result } = useConstitutionStore()
const handleLogout = () => {
Alert.alert('提示', '确定要退出登录吗?', [
{ text: '取消', style: 'cancel' },
{ text: '确定', style: 'destructive', onPress: () => logout() },
])
}
return (
<ScrollView style={styles.container}>
{/* 用户信息卡片 */}
<View style={styles.headerCard}>
<View style={styles.userInfo}>
<Avatar.Text
size={64}
label={user?.nickname?.charAt(0) || 'U'}
style={styles.avatar}
/>
<View style={styles.userText}>
<Text style={styles.nickname}>{user?.nickname || '用户'}</Text>
<Text style={styles.phone}>{user?.phone}</Text>
{result && (
<View style={styles.constitutionTag}>
<Icon name="heart-pulse" size={14} color="#10B981" />
<Text style={styles.constitutionText}>
{constitutionNames[result.primaryType]}
</Text>
</View>
)}
</View>
</View>
</View>
{/* 健康管理 */}
<Card style={styles.menuCard}>
<Card.Title title="健康管理" titleStyle={styles.menuTitle} />
<List.Item
title="健康档案"
description="查看和管理您的健康信息"
left={(props) => (
<View style={[styles.iconBg, { backgroundColor: '#ECFDF5' }]}>
<Icon name="file-document" size={24} color="#10B981" />
</View>
)}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => navigation.navigate('HealthRecord')}
style={styles.listItem}
/>
<Divider />
<List.Item
title="体质报告"
description={result ? `当前体质:${constitutionNames[result.primaryType]}` : '暂无测评记录'}
left={(props) => (
<View style={[styles.iconBg, { backgroundColor: '#EDE9FE' }]}>
<Icon name="chart-line" size={24} color="#8B5CF6" />
</View>
)}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => navigation.navigate('ConstitutionTab', { screen: 'ConstitutionResult' })}
style={styles.listItem}
/>
<Divider />
<List.Item
title="对话历史"
description="查看AI咨询记录"
left={(props) => (
<View style={[styles.iconBg, { backgroundColor: '#DBEAFE' }]}>
<Icon name="chat-processing" size={24} color="#3B82F6" />
</View>
)}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => navigation.navigate('ChatTab')}
style={styles.listItem}
/>
</Card>
{/* 其他设置 */}
<Card style={styles.menuCard}>
<Card.Title title="其他" titleStyle={styles.menuTitle} />
<List.Item
title="健康商城"
description="选购适合您的保健品"
left={(props) => (
<View style={[styles.iconBg, { backgroundColor: '#FEF3C7' }]}>
<Icon name="store" size={24} color="#F59E0B" />
</View>
)}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => Linking.openURL('https://mall.example.com')}
style={styles.listItem}
/>
<Divider />
<List.Item
title="关于我们"
description="了解健康AI助手"
left={(props) => (
<View style={[styles.iconBg, { backgroundColor: '#E5E7EB' }]}>
<Icon name="information" size={24} color="#6B7280" />
</View>
)}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => Alert.alert('关于我们', '健康AI助手 v1.0.0\n\n结合中医体质辨识理论,为您提供个性化健康建议。')}
style={styles.listItem}
/>
</Card>
{/* 退出登录 */}
<Button
mode="outlined"
onPress={handleLogout}
textColor="#EF4444"
style={styles.logoutButton}
>
退出登录
</Button>
<Text style={styles.version}>版本 1.0.0(原型版)</Text>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6' },
headerCard: { backgroundColor: '#10B981', padding: 20, paddingTop: 40 },
userInfo: { flexDirection: 'row', alignItems: 'center' },
avatar: { backgroundColor: 'rgba(255,255,255,0.2)' },
userText: { marginLeft: 16 },
nickname: { fontSize: 20, fontWeight: '600', color: '#fff' },
phone: { fontSize: 14, color: 'rgba(255,255,255,0.8)', marginTop: 4 },
constitutionTag: { flexDirection: 'row', alignItems: 'center', marginTop: 8, backgroundColor: 'rgba(255,255,255,0.2)', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12 },
constitutionText: { fontSize: 12, color: '#fff', marginLeft: 4 },
menuCard: { margin: 16, marginBottom: 0, borderRadius: 12 },
menuTitle: { fontSize: 14, color: '#6B7280' },
listItem: { paddingVertical: 4 },
iconBg: { width: 40, height: 40, borderRadius: 20, justifyContent: 'center', alignItems: 'center', marginLeft: 8 },
logoutButton: { margin: 16, borderColor: '#EF4444', borderRadius: 8 },
version: { textAlign: 'center', fontSize: 12, color: '#9CA3AF', marginBottom: 32 },
})
export default ProfileHomeScreen
```
### 步骤 2:健康档案页面
创建 `src/screens/profile/HealthRecordScreen.tsx`
```typescript
import React from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { Text, Card, Chip } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useAuthStore } from '../../stores/useAuthStore'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionNames, constitutionDescriptions } from '../../mock/constitution'
const HealthRecordScreen = () => {
const { user } = useAuthStore()
const { result } = useConstitutionStore()
// 模拟健康档案数据
const mockProfile = {
basicInfo: {
name: user?.nickname || '用户',
gender: '男',
age: 45,
height: 170,
weight: 68,
bloodType: 'A型',
},
medicalHistory: ['高血压', '轻度脂肪肝'],
allergyRecords: ['青霉素'],
lifestyleInfo: {
sleepTime: '23:00',
wakeTime: '07:00',
exerciseFrequency: '每周2-3次',
},
}
const bmi = (mockProfile.basicInfo.weight / Math.pow(mockProfile.basicInfo.height / 100, 2)).toFixed(1)
return (
<ScrollView style={styles.container}>
{/* 基础信息 */}
<Card style={styles.card}>
<Card.Title
title="基础信息"
left={(props) => <Icon name="account" size={24} color="#10B981" />}
/>
<Card.Content>
<View style={styles.infoGrid}>
<InfoItem label="姓名" value={mockProfile.basicInfo.name} />
<InfoItem label="性别" value={mockProfile.basicInfo.gender} />
<InfoItem label="年龄" value={`${mockProfile.basicInfo.age}岁`} />
<InfoItem label="身高" value={`${mockProfile.basicInfo.height}cm`} />
<InfoItem label="体重" value={`${mockProfile.basicInfo.weight}kg`} />
<InfoItem label="BMI" value={bmi} />
<InfoItem label="血型" value={mockProfile.basicInfo.bloodType} />
</View>
</Card.Content>
</Card>
{/* 体质信息 */}
<Card style={styles.card}>
<Card.Title
title="体质信息"
left={(props) => <Icon name="heart-pulse" size={24} color="#10B981" />}
/>
<Card.Content>
{result ? (
<View style={styles.constitutionInfo}>
<Chip style={styles.constitutionChip} textStyle={styles.constitutionChipText}>
{constitutionNames[result.primaryType]}
</Chip>
<Text style={styles.constitutionDesc}>
{constitutionDescriptions[result.primaryType].description}
</Text>
<Text style={styles.assessedTime}>
测评时间:{new Date(result.assessedAt).toLocaleDateString()}
</Text>
</View>
) : (
<Text style={styles.emptyText}>暂无体质测评记录</Text>
)}
</Card.Content>
</Card>
{/* 既往病史 */}
<Card style={styles.card}>
<Card.Title
title="既往病史"
left={(props) => <Icon name="medical-bag" size={24} color="#10B981" />}
/>
<Card.Content>
{mockProfile.medicalHistory.length > 0 ? (
<View style={styles.tagList}>
{mockProfile.medicalHistory.map((item, index) => (
<Chip key={index} style={styles.tag}>{item}</Chip>
))}
</View>
) : (
<Text style={styles.emptyText}>暂无病史记录</Text>
)}
</Card.Content>
</Card>
{/* 过敏信息 */}
<Card style={styles.card}>
<Card.Title
title="过敏信息"
left={(props) => <Icon name="alert-circle" size={24} color="#EF4444" />}
/>
<Card.Content>
{mockProfile.allergyRecords.length > 0 ? (
<View style={styles.tagList}>
{mockProfile.allergyRecords.map((item, index) => (
<Chip key={index} style={styles.allergyTag}>{item}</Chip>
))}
</View>
) : (
<Text style={styles.emptyText}>暂无过敏信息</Text>
)}
</Card.Content>
</Card>
{/* 生活习惯 */}
<Card style={styles.card}>
<Card.Title
title="生活习惯"
left={(props) => <Icon name="calendar-clock" size={24} color="#10B981" />}
/>
<Card.Content>
<View style={styles.infoGrid}>
<InfoItem label="入睡时间" value={mockProfile.lifestyleInfo.sleepTime} />
<InfoItem label="起床时间" value={mockProfile.lifestyleInfo.wakeTime} />
<InfoItem label="运动频率" value={mockProfile.lifestyleInfo.exerciseFrequency} />
</View>
</Card.Content>
</Card>
<Text style={styles.note}>
以上为模拟数据,后续将支持编辑和同步
</Text>
</ScrollView>
)
}
const InfoItem = ({ label, value }: { label: string; value: string }) => (
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
)
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F3F4F6', padding: 16 },
card: { borderRadius: 12, marginBottom: 16 },
infoGrid: { flexDirection: 'row', flexWrap: 'wrap' },
infoItem: { width: '50%', marginBottom: 16 },
infoLabel: { fontSize: 12, color: '#9CA3AF', marginBottom: 4 },
infoValue: { fontSize: 15, color: '#1F2937' },
constitutionInfo: { alignItems: 'center' },
constitutionChip: { backgroundColor: '#10B981' },
constitutionChipText: { color: '#fff', fontSize: 16 },
constitutionDesc: { marginTop: 12, fontSize: 14, color: '#6B7280', textAlign: 'center', lineHeight: 22 },
assessedTime: { marginTop: 8, fontSize: 12, color: '#9CA3AF' },
tagList: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tag: { backgroundColor: '#ECFDF5' },
allergyTag: { backgroundColor: '#FEE2E2' },
emptyText: { color: '#9CA3AF', textAlign: 'center', paddingVertical: 16 },
note: { fontSize: 12, color: '#9CA3AF', textAlign: 'center', marginBottom: 24 },
})
export default HealthRecordScreen
```
---
## 验收标准
- [ ] 个人中心页面正常显示
- [ ] 用户信息显示正确
- [ ] 体质标签显示(如已测评)
- [ ] 菜单导航正常
- [ ] 健康档案数据显示
- [ ] 退出登录功能正常
---
## 预计耗时
25-30 分钟
---
## 完成
恭喜!APP 原型开发任务全部完成!
---
## 下一步
进入 `03-Web原型开发/01-项目初始化和模拟数据.md`

195
TODOS/02-后端开发/01-项目结构初始化.md

@ -0,0 +1,195 @@
# 01-后端项目结构初始化
## 目标
创建 Go + Gin 后端项目的基础目录结构和配置文件。
---
## 前置要求
- Go 1.21+ 已安装
- 环境变量已配置
---
## 实施步骤
### 步骤 1:创建项目目录
```bash
cd I:\apps\demo\healthApps
mkdir -p server
cd server
```
### 步骤 2:初始化 Go 模块
```bash
go mod init health-ai
```
### 步骤 3:创建目录结构
```bash
mkdir -p cmd/server
mkdir -p internal/api/handler
mkdir -p internal/api/middleware
mkdir -p internal/model
mkdir -p internal/service
mkdir -p internal/repository/impl
mkdir -p internal/config
mkdir -p internal/database
mkdir -p pkg/jwt
mkdir -p pkg/response
mkdir -p pkg/utils
mkdir -p data
```
### 步骤 4:安装核心依赖
```bash
# Web 框架
go get -u github.com/gin-gonic/gin
# ORM
go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite
# 配置管理
go get -u github.com/spf13/viper
# 日志
go get -u go.uber.org/zap
# JWT
go get -u github.com/golang-jwt/jwt/v5
# 密码加密
go get -u golang.org/x/crypto/bcrypt
# 参数验证
go get -u github.com/go-playground/validator/v10
# 跨域
go get -u github.com/gin-contrib/cors
```
### 步骤 5:创建配置文件
创建 `server/config.yaml`
```yaml
server:
port: 8080
mode: debug # debug, release, test
database:
driver: sqlite # sqlite, postgres, mysql
sqlite:
path: ./data/health.db
postgres:
host: localhost
port: 5432
user: postgres
password: ""
dbname: health_app
mysql:
host: localhost
port: 3306
user: root
password: ""
dbname: health_app
jwt:
secret: your-secret-key-change-in-production
expire_hours: 24
ai:
provider: openai # openai, qwen
api_key: ""
base_url: ""
```
### 步骤 6:创建入口文件
创建 `server/cmd/server/main.go`
```go
package main
import (
"log"
)
func main() {
log.Println("Health AI Server Starting...")
// TODO: 初始化配置、数据库、路由
}
```
### 步骤 7:验证项目
```bash
cd server
go mod tidy
go run cmd/server/main.go
# 输出: Health AI Server Starting...
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `server/go.mod` | Go 模块定义 |
| `server/config.yaml` | 配置文件 |
| `server/cmd/server/main.go` | 程序入口 |
---
## 最终目录结构
```
server/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── api/
│ │ ├── handler/
│ │ └── middleware/
│ ├── model/
│ ├── service/
│ ├── repository/
│ │ └── impl/
│ ├── config/
│ └── database/
├── pkg/
│ ├── jwt/
│ ├── response/
│ └── utils/
├── data/
├── config.yaml
├── go.mod
└── go.sum
```
---
## 验收标准
- [ ] 目录结构创建完成
- [ ] `go mod tidy` 无报错
- [ ] `go run cmd/server/main.go` 正常输出
---
## 预计耗时
10-15 分钟
---
## 下一步
完成后进入 `02-后端开发/02-数据库和模型设计.md`

384
TODOS/02-后端开发/02-数据库和模型设计.md

@ -0,0 +1,384 @@
# 02-数据库和模型设计
## 目标
实现数据库连接模块和所有数据模型定义,支持多数据库切换。
---
## 前置要求
- 项目结构已初始化
- 依赖已安装
---
## 实施步骤
### 步骤 1:创建配置加载模块
创建 `server/internal/config/config.go`
```go
package config
import (
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
AI AIConfig
}
type ServerConfig struct {
Port int
Mode string
}
type DatabaseConfig struct {
Driver string
SQLite SQLiteConfig
Postgres PostgresConfig
MySQL MySQLConfig
}
type SQLiteConfig struct {
Path string
}
type PostgresConfig struct {
Host string
Port int
User string
Password string
DBName string
}
type MySQLConfig struct {
Host string
Port int
User string
Password string
DBName string
}
type JWTConfig struct {
Secret string
ExpireHours int `mapstructure:"expire_hours"`
}
type AIConfig struct {
Provider string
APIKey string `mapstructure:"api_key"`
BaseURL string `mapstructure:"base_url"`
}
var AppConfig *Config
func LoadConfig(path string) error {
viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil {
return err
}
AppConfig = &Config{}
return viper.Unmarshal(AppConfig)
}
```
### 步骤 2:创建数据库连接模块
创建 `server/internal/database/database.go`
```go
package database
import (
"fmt"
"health-ai/internal/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDatabase(cfg *config.DatabaseConfig) error {
var err error
switch cfg.Driver {
case "sqlite":
DB, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), &gorm.Config{})
case "postgres":
// TODO: 添加 PostgreSQL 支持
// dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
// cfg.Postgres.Host, cfg.Postgres.Port, cfg.Postgres.User, cfg.Postgres.Password, cfg.Postgres.DBName)
// DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
return fmt.Errorf("postgres driver not implemented yet")
case "mysql":
// TODO: 添加 MySQL 支持
return fmt.Errorf("mysql driver not implemented yet")
default:
return fmt.Errorf("unsupported database driver: %s", cfg.Driver)
}
return err
}
func AutoMigrate(models ...interface{}) error {
return DB.AutoMigrate(models...)
}
```
### 步骤 3:创建数据模型
创建 `server/internal/model/user.go`
```go
package model
import (
"time"
"gorm.io/gorm"
)
// User 用户表
type User struct {
gorm.Model
Phone string `gorm:"uniqueIndex;size:20"`
Email string `gorm:"uniqueIndex;size:100"`
PasswordHash string `gorm:"size:255"`
Nickname string `gorm:"size:50"`
Avatar string `gorm:"size:255"`
SurveyCompleted bool `gorm:"default:false"`
}
// HealthProfile 健康档案
type HealthProfile struct {
gorm.Model
UserID uint `gorm:"uniqueIndex"`
Name string `gorm:"size:50"`
BirthDate *time.Time
Gender string `gorm:"size:10"` // male, female
Height float64 // cm
Weight float64 // kg
BMI float64
BloodType string `gorm:"size:10"` // A, B, AB, O
Occupation string `gorm:"size:50"`
MaritalStatus string `gorm:"size:20"` // single, married, divorced
Region string `gorm:"size:100"`
}
// LifestyleInfo 生活习惯
type LifestyleInfo struct {
gorm.Model
UserID uint `gorm:"uniqueIndex"`
SleepTime string `gorm:"size:10"` // HH:MM
WakeTime string `gorm:"size:10"`
SleepQuality string `gorm:"size:20"` // good, normal, poor
MealRegularity string `gorm:"size:20"` // regular, irregular
DietPreference string `gorm:"size:50"` // 偏好
DailyWaterML int // 每日饮水量 ml
ExerciseFrequency string `gorm:"size:20"` // never, sometimes, often, daily
ExerciseType string `gorm:"size:100"`
ExerciseDurationMin int // 每次运动时长
IsSmoker bool
AlcoholFrequency string `gorm:"size:20"` // never, sometimes, often
}
```
创建 `server/internal/model/health.go`
```go
package model
import "gorm.io/gorm"
// MedicalHistory 既往病史
type MedicalHistory struct {
gorm.Model
HealthProfileID uint
DiseaseName string `gorm:"size:100"`
DiseaseType string `gorm:"size:50"` // chronic, surgery, other
DiagnosedDate string `gorm:"size:20"`
Status string `gorm:"size:20"` // cured, treating, controlled
Notes string `gorm:"type:text"`
}
// FamilyHistory 家族病史
type FamilyHistory struct {
gorm.Model
HealthProfileID uint
Relation string `gorm:"size:20"` // father, mother, grandparent
DiseaseName string `gorm:"size:100"`
Notes string `gorm:"type:text"`
}
// AllergyRecord 过敏记录
type AllergyRecord struct {
gorm.Model
HealthProfileID uint
AllergyType string `gorm:"size:20"` // drug, food, other
Allergen string `gorm:"size:100"`
Severity string `gorm:"size:20"` // mild, moderate, severe
ReactionDesc string `gorm:"type:text"`
}
```
创建 `server/internal/model/constitution.go`
```go
package model
import (
"time"
"gorm.io/gorm"
)
// ConstitutionAssessment 体质测评记录
type ConstitutionAssessment struct {
gorm.Model
UserID uint
AssessedAt time.Time
Scores string `gorm:"type:text"` // JSON: 各体质得分
PrimaryConstitution string `gorm:"size:20"` // 主要体质
SecondaryConstitutions string `gorm:"type:text"` // JSON: 次要体质
Recommendations string `gorm:"type:text"` // JSON: 调养建议
}
// AssessmentAnswer 问卷答案
type AssessmentAnswer struct {
gorm.Model
AssessmentID uint
QuestionID uint
Score int // 1-5
}
// QuestionBank 问卷题库
type QuestionBank struct {
gorm.Model
ConstitutionType string `gorm:"size:20"` // 体质类型
QuestionText string `gorm:"type:text"`
Options string `gorm:"type:text"` // JSON: 选项
OrderNum int
}
```
创建 `server/internal/model/conversation.go`
```go
package model
import "gorm.io/gorm"
// Conversation 对话
type Conversation struct {
gorm.Model
UserID uint
Title string `gorm:"size:200"`
Messages []Message
}
// Message 消息
type Message struct {
gorm.Model
ConversationID uint
Role string `gorm:"size:20"` // user, assistant, system
Content string `gorm:"type:text"`
}
```
### 步骤 4:创建模型聚合文件
创建 `server/internal/model/models.go`
```go
package model
// AllModels 返回所有需要迁移的模型
func AllModels() []interface{} {
return []interface{}{
&User{},
&HealthProfile{},
&LifestyleInfo{},
&MedicalHistory{},
&FamilyHistory{},
&AllergyRecord{},
&ConstitutionAssessment{},
&AssessmentAnswer{},
&QuestionBank{},
&Conversation{},
&Message{},
}
}
```
### 步骤 5:更新主程序
更新 `server/cmd/server/main.go`
```go
package main
import (
"log"
"health-ai/internal/config"
"health-ai/internal/database"
"health-ai/internal/model"
)
func main() {
// 加载配置
if err := config.LoadConfig("config.yaml"); err != nil {
log.Fatalf("Failed to load config: %v", err)
}
log.Println("Config loaded")
// 初始化数据库
if err := database.InitDatabase(&config.AppConfig.Database); err != nil {
log.Fatalf("Failed to init database: %v", err)
}
log.Println("Database connected")
// 自动迁移
if err := database.AutoMigrate(model.AllModels()...); err != nil {
log.Fatalf("Failed to migrate: %v", err)
}
log.Println("Database migrated")
log.Println("Health AI Server Ready!")
}
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `internal/config/config.go` | 配置加载 |
| `internal/database/database.go` | 数据库连接 |
| `internal/model/user.go` | 用户相关模型 |
| `internal/model/health.go` | 健康相关模型 |
| `internal/model/constitution.go` | 体质相关模型 |
| `internal/model/conversation.go` | 对话相关模型 |
| `internal/model/models.go` | 模型聚合 |
---
## 验收标准
- [ ] 配置文件可正常加载
- [ ] SQLite 数据库文件自动创建
- [ ] 所有表自动迁移成功
- [ ] `data/health.db` 文件生成
---
## 预计耗时
20-30 分钟
---
## 下一步
完成后进入 `02-后端开发/03-用户认证模块.md`

522
TODOS/02-后端开发/03-用户认证模块.md

@ -0,0 +1,522 @@
# 03-用户认证模块
## 目标
实现用户注册、登录、Token 刷新等认证功能。
---
## 前置要求
- 数据库和模型已完成
- JWT 依赖已安装
---
## 实施步骤
### 步骤 1:创建统一响应工具
创建 `server/pkg/response/response.go`
```go
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: 0,
Message: "success",
Data: data,
})
}
func Error(c *gin.Context, code int, message string) {
c.JSON(http.StatusOK, Response{
Code: code,
Message: message,
})
}
func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, Response{
Code: 401,
Message: message,
})
}
```
### 步骤 2:创建 JWT 工具
创建 `server/pkg/jwt/jwt.go`
```go
package jwt
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret []byte
var expireHours int
func Init(secret string, hours int) {
jwtSecret = []byte(secret)
expireHours = hours
}
type Claims struct {
UserID uint `json:"user_id"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint) (string, error) {
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
```
### 步骤 3:创建认证中间件
创建 `server/internal/api/middleware/auth.go`
```go
package middleware
import (
"strings"
"health-ai/pkg/jwt"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Unauthorized(c, "未提供认证信息")
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.Unauthorized(c, "认证格式错误")
c.Abort()
return
}
claims, err := jwt.ParseToken(parts[1])
if err != nil {
response.Unauthorized(c, "Token无效或已过期")
c.Abort()
return
}
c.Set("userID", claims.UserID)
c.Next()
}
}
func GetUserID(c *gin.Context) uint {
userID, _ := c.Get("userID")
return userID.(uint)
}
```
### 步骤 4:创建用户 Repository
创建 `server/internal/repository/interface.go`
```go
package repository
import "health-ai/internal/model"
type UserRepository interface {
Create(user *model.User) error
GetByID(id uint) (*model.User, error)
GetByPhone(phone string) (*model.User, error)
GetByEmail(email string) (*model.User, error)
Update(user *model.User) error
}
```
创建 `server/internal/repository/impl/user.go`
```go
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type UserRepositoryImpl struct{}
func NewUserRepository() *UserRepositoryImpl {
return &UserRepositoryImpl{}
}
func (r *UserRepositoryImpl) Create(user *model.User) error {
return database.DB.Create(user).Error
}
func (r *UserRepositoryImpl) GetByID(id uint) (*model.User, error) {
var user model.User
err := database.DB.First(&user, id).Error
return &user, err
}
func (r *UserRepositoryImpl) GetByPhone(phone string) (*model.User, error) {
var user model.User
err := database.DB.Where("phone = ?", phone).First(&user).Error
return &user, err
}
func (r *UserRepositoryImpl) GetByEmail(email string) (*model.User, error) {
var user model.User
err := database.DB.Where("email = ?", email).First(&user).Error
return &user, err
}
func (r *UserRepositoryImpl) Update(user *model.User) error {
return database.DB.Save(user).Error
}
```
### 步骤 5:创建认证 Service
创建 `server/internal/service/auth.go`
```go
package service
import (
"errors"
"health-ai/internal/model"
"health-ai/internal/repository/impl"
"health-ai/pkg/jwt"
"golang.org/x/crypto/bcrypt"
)
type AuthService struct {
userRepo *impl.UserRepositoryImpl
}
func NewAuthService() *AuthService {
return &AuthService{
userRepo: impl.NewUserRepository(),
}
}
type RegisterRequest struct {
Phone string `json:"phone" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
}
type LoginRequest struct {
Phone string `json:"phone" binding:"required"`
Password string `json:"password" binding:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
UserID uint `json:"user_id"`
Nickname string `json:"nickname"`
SurveyCompleted bool `json:"survey_completed"`
}
func (s *AuthService) Register(req *RegisterRequest) (*AuthResponse, error) {
// 检查手机号是否已注册
existing, _ := s.userRepo.GetByPhone(req.Phone)
if existing.ID > 0 {
return nil, errors.New("手机号已注册")
}
// 加密密码
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// 创建用户
user := &model.User{
Phone: req.Phone,
PasswordHash: string(hash),
Nickname: req.Nickname,
}
if user.Nickname == "" {
user.Nickname = "用户" + req.Phone[len(req.Phone)-4:]
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
// 生成 Token
token, err := jwt.GenerateToken(user.ID)
if err != nil {
return nil, err
}
return &AuthResponse{
Token: token,
UserID: user.ID,
Nickname: user.Nickname,
SurveyCompleted: user.SurveyCompleted,
}, nil
}
func (s *AuthService) Login(req *LoginRequest) (*AuthResponse, error) {
user, err := s.userRepo.GetByPhone(req.Phone)
if err != nil {
return nil, errors.New("用户不存在")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
return nil, errors.New("密码错误")
}
token, err := jwt.GenerateToken(user.ID)
if err != nil {
return nil, err
}
return &AuthResponse{
Token: token,
UserID: user.ID,
Nickname: user.Nickname,
SurveyCompleted: user.SurveyCompleted,
}, nil
}
```
### 步骤 6:创建认证 Handler
创建 `server/internal/api/handler/auth.go`
```go
package handler
import (
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler() *AuthHandler {
return &AuthHandler{
authService: service.NewAuthService(),
}
}
func (h *AuthHandler) Register(c *gin.Context) {
var req service.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
result, err := h.authService.Register(&req)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
func (h *AuthHandler) Login(c *gin.Context) {
var req service.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
result, err := h.authService.Login(&req)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
```
### 步骤 7:创建路由配置
创建 `server/internal/api/router.go`
```go
package api
import (
"health-ai/internal/api/handler"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func SetupRouter(mode string) *gin.Engine {
gin.SetMode(mode)
r := gin.Default()
// 跨域配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
}))
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// API 路由组
apiGroup := r.Group("/api")
{
// 认证路由(无需登录)
authHandler := handler.NewAuthHandler()
authGroup := apiGroup.Group("/auth")
{
authGroup.POST("/register", authHandler.Register)
authGroup.POST("/login", authHandler.Login)
}
}
return r
}
```
### 步骤 8:更新主程序
更新 `server/cmd/server/main.go`,添加路由启动:
```go
// ... 前面的初始化代码 ...
// 初始化 JWT
jwt.Init(config.AppConfig.JWT.Secret, config.AppConfig.JWT.ExpireHours)
// 启动服务器
router := api.SetupRouter(config.AppConfig.Server.Mode)
addr := fmt.Sprintf(":%d", config.AppConfig.Server.Port)
log.Printf("Server running on http://localhost%s", addr)
router.Run(addr)
```
---
## API 接口说明
### POST /api/auth/register
注册新用户
**请求体:**
```json
{
"phone": "13800138000",
"password": "123456",
"nickname": "小明"
}
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"token": "eyJhbGc...",
"user_id": 1,
"nickname": "小明",
"survey_completed": false
}
}
```
### POST /api/auth/login
用户登录
**请求体:**
```json
{
"phone": "13800138000",
"password": "123456"
}
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `pkg/response/response.go` | 统一响应 |
| `pkg/jwt/jwt.go` | JWT 工具 |
| `internal/api/middleware/auth.go` | 认证中间件 |
| `internal/repository/interface.go` | Repository 接口 |
| `internal/repository/impl/user.go` | 用户 Repository |
| `internal/service/auth.go` | 认证 Service |
| `internal/api/handler/auth.go` | 认证 Handler |
| `internal/api/router.go` | 路由配置 |
---
## 验收标准
- [ ] 服务启动成功,监听 8080 端口
- [ ] `/health` 返回 `{"status": "ok"}`
- [ ] 注册接口正常创建用户
- [ ] 登录接口返回有效 Token
- [ ] 密码错误返回正确错误信息
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `02-后端开发/04-健康调查模块.md`

515
TODOS/02-后端开发/04-健康调查模块.md

@ -0,0 +1,515 @@
# 04-健康调查模块
## 目标
实现新用户健康调查功能,包括基础信息、生活习惯、病史、过敏史等信息的提交和管理。
---
## 前置要求
- 用户认证模块已完成
- 数据模型已定义
---
## 实施步骤
### 步骤 1:创建健康档案 Repository
创建 `server/internal/repository/impl/health.go`
```go
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type HealthRepository struct{}
func NewHealthRepository() *HealthRepository {
return &HealthRepository{}
}
// HealthProfile
func (r *HealthRepository) CreateProfile(profile *model.HealthProfile) error {
return database.DB.Create(profile).Error
}
func (r *HealthRepository) GetProfileByUserID(userID uint) (*model.HealthProfile, error) {
var profile model.HealthProfile
err := database.DB.Where("user_id = ?", userID).First(&profile).Error
return &profile, err
}
func (r *HealthRepository) UpdateProfile(profile *model.HealthProfile) error {
return database.DB.Save(profile).Error
}
// LifestyleInfo
func (r *HealthRepository) CreateLifestyle(lifestyle *model.LifestyleInfo) error {
return database.DB.Create(lifestyle).Error
}
func (r *HealthRepository) GetLifestyleByUserID(userID uint) (*model.LifestyleInfo, error) {
var lifestyle model.LifestyleInfo
err := database.DB.Where("user_id = ?", userID).First(&lifestyle).Error
return &lifestyle, err
}
func (r *HealthRepository) UpdateLifestyle(lifestyle *model.LifestyleInfo) error {
return database.DB.Save(lifestyle).Error
}
// MedicalHistory
func (r *HealthRepository) CreateMedicalHistory(history *model.MedicalHistory) error {
return database.DB.Create(history).Error
}
func (r *HealthRepository) GetMedicalHistories(profileID uint) ([]model.MedicalHistory, error) {
var histories []model.MedicalHistory
err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error
return histories, err
}
func (r *HealthRepository) DeleteMedicalHistory(id uint) error {
return database.DB.Delete(&model.MedicalHistory{}, id).Error
}
// FamilyHistory
func (r *HealthRepository) CreateFamilyHistory(history *model.FamilyHistory) error {
return database.DB.Create(history).Error
}
func (r *HealthRepository) GetFamilyHistories(profileID uint) ([]model.FamilyHistory, error) {
var histories []model.FamilyHistory
err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error
return histories, err
}
// AllergyRecord
func (r *HealthRepository) CreateAllergyRecord(record *model.AllergyRecord) error {
return database.DB.Create(record).Error
}
func (r *HealthRepository) GetAllergyRecords(profileID uint) ([]model.AllergyRecord, error) {
var records []model.AllergyRecord
err := database.DB.Where("health_profile_id = ?", profileID).Find(&records).Error
return records, err
}
```
### 步骤 2:创建健康调查 Service
创建 `server/internal/service/survey.go`
```go
package service
import (
"health-ai/internal/model"
"health-ai/internal/repository/impl"
)
type SurveyService struct {
healthRepo *impl.HealthRepository
userRepo *impl.UserRepositoryImpl
}
func NewSurveyService() *SurveyService {
return &SurveyService{
healthRepo: impl.NewHealthRepository(),
userRepo: impl.NewUserRepository(),
}
}
// 基础信息请求
type BasicInfoRequest struct {
Name string `json:"name" binding:"required"`
BirthDate string `json:"birth_date"`
Gender string `json:"gender" binding:"required,oneof=male female"`
Height float64 `json:"height" binding:"required"`
Weight float64 `json:"weight" binding:"required"`
BloodType string `json:"blood_type"`
Occupation string `json:"occupation"`
MaritalStatus string `json:"marital_status"`
Region string `json:"region"`
}
// 生活习惯请求
type LifestyleRequest struct {
SleepTime string `json:"sleep_time"`
WakeTime string `json:"wake_time"`
SleepQuality string `json:"sleep_quality"`
MealRegularity string `json:"meal_regularity"`
DietPreference string `json:"diet_preference"`
DailyWaterML int `json:"daily_water_ml"`
ExerciseFrequency string `json:"exercise_frequency"`
ExerciseType string `json:"exercise_type"`
ExerciseDurationMin int `json:"exercise_duration_min"`
IsSmoker bool `json:"is_smoker"`
AlcoholFrequency string `json:"alcohol_frequency"`
}
// 病史请求
type MedicalHistoryRequest struct {
DiseaseName string `json:"disease_name" binding:"required"`
DiseaseType string `json:"disease_type"`
DiagnosedDate string `json:"diagnosed_date"`
Status string `json:"status"`
Notes string `json:"notes"`
}
// 家族病史请求
type FamilyHistoryRequest struct {
Relation string `json:"relation" binding:"required"`
DiseaseName string `json:"disease_name" binding:"required"`
Notes string `json:"notes"`
}
// 过敏记录请求
type AllergyRequest struct {
AllergyType string `json:"allergy_type" binding:"required"`
Allergen string `json:"allergen" binding:"required"`
Severity string `json:"severity"`
ReactionDesc string `json:"reaction_desc"`
}
// 获取调查状态
func (s *SurveyService) GetStatus(userID uint) (map[string]bool, error) {
status := map[string]bool{
"basic_info": false,
"lifestyle": false,
"medical_history": false,
"family_history": false,
"allergy": false,
}
profile, err := s.healthRepo.GetProfileByUserID(userID)
if err == nil && profile.ID > 0 {
status["basic_info"] = true
histories, _ := s.healthRepo.GetMedicalHistories(profile.ID)
status["medical_history"] = len(histories) >= 0 // 可以为空
familyHistories, _ := s.healthRepo.GetFamilyHistories(profile.ID)
status["family_history"] = len(familyHistories) >= 0
allergies, _ := s.healthRepo.GetAllergyRecords(profile.ID)
status["allergy"] = len(allergies) >= 0
}
lifestyle, err := s.healthRepo.GetLifestyleByUserID(userID)
if err == nil && lifestyle.ID > 0 {
status["lifestyle"] = true
}
return status, nil
}
// 提交基础信息
func (s *SurveyService) SubmitBasicInfo(userID uint, req *BasicInfoRequest) error {
// 计算 BMI
heightM := req.Height / 100
bmi := req.Weight / (heightM * heightM)
profile := &model.HealthProfile{
UserID: userID,
Name: req.Name,
Gender: req.Gender,
Height: req.Height,
Weight: req.Weight,
BMI: bmi,
BloodType: req.BloodType,
Occupation: req.Occupation,
MaritalStatus: req.MaritalStatus,
Region: req.Region,
}
// 检查是否已存在
existing, _ := s.healthRepo.GetProfileByUserID(userID)
if existing.ID > 0 {
profile.ID = existing.ID
return s.healthRepo.UpdateProfile(profile)
}
return s.healthRepo.CreateProfile(profile)
}
// 提交生活习惯
func (s *SurveyService) SubmitLifestyle(userID uint, req *LifestyleRequest) error {
lifestyle := &model.LifestyleInfo{
UserID: userID,
SleepTime: req.SleepTime,
WakeTime: req.WakeTime,
SleepQuality: req.SleepQuality,
MealRegularity: req.MealRegularity,
DietPreference: req.DietPreference,
DailyWaterML: req.DailyWaterML,
ExerciseFrequency: req.ExerciseFrequency,
ExerciseType: req.ExerciseType,
ExerciseDurationMin: req.ExerciseDurationMin,
IsSmoker: req.IsSmoker,
AlcoholFrequency: req.AlcoholFrequency,
}
existing, _ := s.healthRepo.GetLifestyleByUserID(userID)
if existing.ID > 0 {
lifestyle.ID = existing.ID
return s.healthRepo.UpdateLifestyle(lifestyle)
}
return s.healthRepo.CreateLifestyle(lifestyle)
}
// 提交病史
func (s *SurveyService) SubmitMedicalHistory(userID uint, req *MedicalHistoryRequest) error {
profile, err := s.healthRepo.GetProfileByUserID(userID)
if err != nil {
return err
}
history := &model.MedicalHistory{
HealthProfileID: profile.ID,
DiseaseName: req.DiseaseName,
DiseaseType: req.DiseaseType,
DiagnosedDate: req.DiagnosedDate,
Status: req.Status,
Notes: req.Notes,
}
return s.healthRepo.CreateMedicalHistory(history)
}
// 提交家族病史
func (s *SurveyService) SubmitFamilyHistory(userID uint, req *FamilyHistoryRequest) error {
profile, err := s.healthRepo.GetProfileByUserID(userID)
if err != nil {
return err
}
history := &model.FamilyHistory{
HealthProfileID: profile.ID,
Relation: req.Relation,
DiseaseName: req.DiseaseName,
Notes: req.Notes,
}
return s.healthRepo.CreateFamilyHistory(history)
}
// 提交过敏信息
func (s *SurveyService) SubmitAllergy(userID uint, req *AllergyRequest) error {
profile, err := s.healthRepo.GetProfileByUserID(userID)
if err != nil {
return err
}
record := &model.AllergyRecord{
HealthProfileID: profile.ID,
AllergyType: req.AllergyType,
Allergen: req.Allergen,
Severity: req.Severity,
ReactionDesc: req.ReactionDesc,
}
return s.healthRepo.CreateAllergyRecord(record)
}
// 标记调查完成
func (s *SurveyService) MarkSurveyCompleted(userID uint) error {
user, err := s.userRepo.GetByID(userID)
if err != nil {
return err
}
user.SurveyCompleted = true
return s.userRepo.Update(user)
}
```
### 步骤 3:创建健康调查 Handler
创建 `server/internal/api/handler/survey.go`
```go
package handler
import (
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type SurveyHandler struct {
surveyService *service.SurveyService
}
func NewSurveyHandler() *SurveyHandler {
return &SurveyHandler{
surveyService: service.NewSurveyService(),
}
}
func (h *SurveyHandler) GetStatus(c *gin.Context) {
userID := middleware.GetUserID(c)
status, err := h.surveyService.GetStatus(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, status)
}
func (h *SurveyHandler) SubmitBasicInfo(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.BasicInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitBasicInfo(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
func (h *SurveyHandler) SubmitLifestyle(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.LifestyleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitLifestyle(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
func (h *SurveyHandler) SubmitMedicalHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.MedicalHistoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitMedicalHistory(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
func (h *SurveyHandler) SubmitFamilyHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.FamilyHistoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitFamilyHistory(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
func (h *SurveyHandler) SubmitAllergy(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.AllergyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitAllergy(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
```
### 步骤 4:更新路由配置
`router.go` 中添加调查路由:
```go
// 健康调查路由(需要登录)
surveyHandler := handler.NewSurveyHandler()
surveyGroup := apiGroup.Group("/survey")
surveyGroup.Use(middleware.AuthRequired())
{
surveyGroup.GET("/status", surveyHandler.GetStatus)
surveyGroup.POST("/basic-info", surveyHandler.SubmitBasicInfo)
surveyGroup.POST("/lifestyle", surveyHandler.SubmitLifestyle)
surveyGroup.POST("/medical-history", surveyHandler.SubmitMedicalHistory)
surveyGroup.POST("/family-history", surveyHandler.SubmitFamilyHistory)
surveyGroup.POST("/allergy", surveyHandler.SubmitAllergy)
}
```
---
## API 接口说明
### GET /api/survey/status
获取调查完成状态(需认证)
### POST /api/survey/basic-info
提交基础信息
### POST /api/survey/lifestyle
提交生活习惯
### POST /api/survey/medical-history
提交病史(可多次提交)
### POST /api/survey/family-history
提交家族病史(可多次提交)
### POST /api/survey/allergy
提交过敏信息(可多次提交)
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `internal/repository/impl/health.go` | 健康 Repository |
| `internal/service/survey.go` | 调查 Service |
| `internal/api/handler/survey.go` | 调查 Handler |
---
## 验收标准
- [ ] 获取调查状态接口正常
- [ ] 基础信息提交成功,BMI 自动计算
- [ ] 生活习惯提交成功
- [ ] 病史、家族史、过敏信息可多次添加
- [ ] 所有接口需要认证
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `02-后端开发/05-体质辨识模块.md`

550
TODOS/02-后端开发/05-体质辨识模块.md

@ -0,0 +1,550 @@
# 05-体质辨识模块
## 目标
实现中医体质辨识问卷功能,包括问卷题库、答案提交、体质计算和调养建议生成。
---
## 前置要求
- 健康调查模块已完成
- 数据模型已定义
---
## 实施步骤
### 步骤 1:创建体质常量定义
创建 `server/internal/model/constitution_const.go`
```go
package model
// 九种体质类型
const (
ConstitutionPinghe = "pinghe" // 平和质
ConstitutionQixu = "qixu" // 气虚质
ConstitutionYangxu = "yangxu" // 阳虚质
ConstitutionYinxu = "yinxu" // 阴虚质
ConstitutionTanshi = "tanshi" // 痰湿质
ConstitutionShire = "shire" // 湿热质
ConstitutionXueyu = "xueyu" // 血瘀质
ConstitutionQiyu = "qiyu" // 气郁质
ConstitutionTebing = "tebing" // 特禀质
)
// 体质名称映射
var ConstitutionNames = map[string]string{
ConstitutionPinghe: "平和质",
ConstitutionQixu: "气虚质",
ConstitutionYangxu: "阳虚质",
ConstitutionYinxu: "阴虚质",
ConstitutionTanshi: "痰湿质",
ConstitutionShire: "湿热质",
ConstitutionXueyu: "血瘀质",
ConstitutionQiyu: "气郁质",
ConstitutionTebing: "特禀质",
}
// 体质特征描述
var ConstitutionDescriptions = map[string]string{
ConstitutionPinghe: "阴阳气血调和,体态适中,面色红润,精力充沛",
ConstitutionQixu: "元气不足,容易疲劳,气短懒言,易出汗",
ConstitutionYangxu: "阳气不足,畏寒怕冷,手脚冰凉,喜热饮",
ConstitutionYinxu: "阴液亏少,口燥咽干,手足心热,盗汗",
ConstitutionTanshi: "痰湿凝聚,形体肥胖,腹部肥满,痰多",
ConstitutionShire: "湿热内蕴,面垢油光,口苦口干,大便黏滞",
ConstitutionXueyu: "血行不畅,肤色晦暗,易生斑点,健忘",
ConstitutionQiyu: "气机郁滞,情绪低落,多愁善感,胸闷",
ConstitutionTebing: "先天失常,过敏体质,易打喷嚏,皮肤易过敏",
}
// 体质调养建议
var ConstitutionRecommendations = map[string]map[string]string{
ConstitutionPinghe: {
"diet": "饮食均衡,不偏食,粗细搭配",
"lifestyle": "起居有常,劳逸结合",
"exercise": "可进行各种运动,量力而行",
"emotion": "保持乐观积极的心态",
},
ConstitutionQixu: {
"diet": "宜食益气健脾食物,如山药、大枣、小米",
"lifestyle": "避免劳累,保证充足睡眠",
"exercise": "宜柔和运动,如太极拳、散步",
"emotion": "避免过度思虑",
},
ConstitutionYangxu: {
"diet": "宜食温阳食物,如羊肉、韭菜、生姜",
"lifestyle": "注意保暖,避免受寒",
"exercise": "宜温和运动,避免大汗",
"emotion": "保持积极乐观",
},
ConstitutionYinxu: {
"diet": "宜食滋阴食物,如百合、银耳、枸杞",
"lifestyle": "避免熬夜,保持环境湿润",
"exercise": "宜静养,避免剧烈运动",
"emotion": "避免急躁易怒",
},
ConstitutionTanshi: {
"diet": "饮食清淡,少食肥甘厚味,宜食薏米、冬瓜",
"lifestyle": "居住环境宜干燥通风",
"exercise": "坚持运动,促进代谢",
"emotion": "保持心情舒畅",
},
ConstitutionShire: {
"diet": "饮食清淡,宜食苦瓜、绿豆、薏米",
"lifestyle": "避免湿热环境,保持皮肤清洁",
"exercise": "适当运动,出汗排湿",
"emotion": "保持平和心态",
},
ConstitutionXueyu: {
"diet": "宜食活血化瘀食物,如山楂、黑木耳",
"lifestyle": "避免久坐,适当活动",
"exercise": "坚持有氧运动,促进血液循环",
"emotion": "保持心情愉快",
},
ConstitutionQiyu: {
"diet": "宜食行气解郁食物,如玫瑰花、佛手",
"lifestyle": "多参加社交活动",
"exercise": "宜户外运动,舒展身心",
"emotion": "学会疏导情绪,培养兴趣爱好",
},
ConstitutionTebing: {
"diet": "避免食用过敏食物,饮食清淡",
"lifestyle": "避免接触过敏原,保持环境清洁",
"exercise": "适度运动,增强体质",
"emotion": "保持心态平和",
},
}
```
### 步骤 2:创建问卷题库初始化
创建 `server/internal/database/seed.go`
```go
package database
import (
"encoding/json"
"health-ai/internal/model"
)
// 初始化问卷题库
func SeedQuestionBank() error {
// 检查是否已有数据
var count int64
DB.Model(&model.QuestionBank{}).Count(&count)
if count > 0 {
return nil
}
questions := getQuestions()
for _, q := range questions {
if err := DB.Create(&q).Error; err != nil {
return err
}
}
return nil
}
func getQuestions() []model.QuestionBank {
options, _ := json.Marshal([]string{"没有", "很少", "有时", "经常", "总是"})
optStr := string(options)
return []model.QuestionBank{
// 平和质 (8题)
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您精力充沛吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您能适应外界自然和社会环境的变化吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易失眠吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 8},
// 气虚质 (8题)
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易气短(呼吸短促,接不上气)吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易心慌吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易头晕或站起时晕眩吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您喜欢安静、懒得说话吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您活动量稍大就容易出虚汗吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 8},
// 阳虚质 (7题)
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您手脚发凉吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您胃脘部、背部或腰膝部怕冷吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您穿的衣服总比别人多吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您吃凉东西会感到不舒服或怕吃凉东西吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您受凉或吃凉的东西后,容易拉肚子吗?", Options: optStr, OrderNum: 7},
// 阴虚质 (8题)
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到手脚心发热吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感觉身体、脸上发热吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您皮肤或口唇干吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您口唇的颜色比一般人红吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您容易便秘或大便干燥吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您面部两颧潮红或偏红吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到眼睛干涩吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到口干咽燥、总想喝水吗?", Options: optStr, OrderNum: 8},
// 痰湿质 (8题)
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到胸闷或腹部胀满吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到身体沉重不轻松或不爽快吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您腹部肥满松软吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您额头部位油脂分泌多吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您上眼睑比别人肿吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您嘴里有黏黏的感觉吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您平时痰多吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您舌苔厚腻或有舌苔厚厚的感觉吗?", Options: optStr, OrderNum: 8},
// 湿热质 (7题)
{ConstitutionType: model.ConstitutionShire, QuestionText: "您面部或鼻部有油腻感或油光发亮吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您脸上容易生痤疮或皮肤容易生疮疖吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您感到口苦或嘴里有异味吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您大便黏滞不爽、有解不尽的感觉吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您小便时尿道有发热感、尿色浓吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您带下色黄(白带颜色发黄)吗?(限女性回答)", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您的阴囊部位潮湿吗?(限男性回答)", Options: optStr, OrderNum: 7},
// 血瘀质 (7题)
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您皮肤在不知不觉中会出现青紫瘀斑吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您两颧部有细微红丝吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您身体上有哪里疼痛吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您面色晦暗或容易出现褐斑吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易有黑眼圈吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您口唇颜色偏暗吗?", Options: optStr, OrderNum: 7},
// 气郁质 (7题)
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您精神紧张、焦虑不安吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您多愁善感、感情脆弱吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您容易感到害怕或受到惊吓吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您胁肋部或乳房胀痛吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您无缘无故叹气吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您咽喉部有异物感吗?", Options: optStr, OrderNum: 7},
// 特禀质 (7题)
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会打喷嚏吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会鼻塞、流鼻涕吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您有因季节变化、温度变化或异味引起的咳嗽吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您容易过敏吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤容易起荨麻疹吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤一抓就红,并出现抓痕吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤或身上容易出现紫红色瘀点、瘀斑吗?", Options: optStr, OrderNum: 7},
}
}
```
### 步骤 3:创建体质 Repository
创建 `server/internal/repository/impl/constitution.go`
```go
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type ConstitutionRepository struct{}
func NewConstitutionRepository() *ConstitutionRepository {
return &ConstitutionRepository{}
}
func (r *ConstitutionRepository) GetQuestions() ([]model.QuestionBank, error) {
var questions []model.QuestionBank
err := database.DB.Order("constitution_type, order_num").Find(&questions).Error
return questions, err
}
func (r *ConstitutionRepository) CreateAssessment(assessment *model.ConstitutionAssessment) error {
return database.DB.Create(assessment).Error
}
func (r *ConstitutionRepository) GetLatestAssessment(userID uint) (*model.ConstitutionAssessment, error) {
var assessment model.ConstitutionAssessment
err := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC").First(&assessment).Error
return &assessment, err
}
func (r *ConstitutionRepository) GetAssessmentHistory(userID uint) ([]model.ConstitutionAssessment, error) {
var assessments []model.ConstitutionAssessment
err := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC").Find(&assessments).Error
return assessments, err
}
```
### 步骤 4:创建体质计算 Service
创建 `server/internal/service/constitution.go`
```go
package service
import (
"encoding/json"
"sort"
"time"
"health-ai/internal/model"
"health-ai/internal/repository/impl"
)
type ConstitutionService struct {
repo *impl.ConstitutionRepository
}
func NewConstitutionService() *ConstitutionService {
return &ConstitutionService{
repo: impl.NewConstitutionRepository(),
}
}
type AnswerRequest struct {
QuestionID uint `json:"question_id" binding:"required"`
Score int `json:"score" binding:"required,min=1,max=5"`
}
type SubmitRequest struct {
Answers []AnswerRequest `json:"answers" binding:"required"`
}
type ConstitutionScore struct {
Type string `json:"type"`
Name string `json:"name"`
Score float64 `json:"score"`
Description string `json:"description"`
}
type AssessmentResult struct {
PrimaryConstitution ConstitutionScore `json:"primary_constitution"`
SecondaryConstitutions []ConstitutionScore `json:"secondary_constitutions"`
AllScores []ConstitutionScore `json:"all_scores"`
Recommendations map[string]map[string]string `json:"recommendations"`
AssessedAt time.Time `json:"assessed_at"`
}
func (s *ConstitutionService) GetQuestions() ([]model.QuestionBank, error) {
return s.repo.GetQuestions()
}
func (s *ConstitutionService) SubmitAssessment(userID uint, req *SubmitRequest) (*AssessmentResult, error) {
// 获取所有问题
questions, err := s.repo.GetQuestions()
if err != nil {
return nil, err
}
// 构建问题ID到体质类型的映射
questionTypeMap := make(map[uint]string)
typeQuestionCount := make(map[string]int)
for _, q := range questions {
questionTypeMap[q.ID] = q.ConstitutionType
typeQuestionCount[q.ConstitutionType]++
}
// 计算各体质原始分
typeScores := make(map[string]int)
for _, answer := range req.Answers {
if cType, ok := questionTypeMap[answer.QuestionID]; ok {
typeScores[cType] += answer.Score
}
}
// 计算转化分
allScores := make([]ConstitutionScore, 0)
for cType, rawScore := range typeScores {
questionCount := typeQuestionCount[cType]
if questionCount == 0 {
continue
}
// 转化分 = (原始分 - 条目数) / (条目数 × 4) × 100
transformedScore := float64(rawScore-questionCount) / float64(questionCount*4) * 100
allScores = append(allScores, ConstitutionScore{
Type: cType,
Name: model.ConstitutionNames[cType],
Score: transformedScore,
Description: model.ConstitutionDescriptions[cType],
})
}
// 按分数排序
sort.Slice(allScores, func(i, j int) bool {
return allScores[i].Score > allScores[j].Score
})
// 判定主要体质和次要体质
var primary ConstitutionScore
var secondary []ConstitutionScore
// 平和质特殊判定
pingheScore := float64(0)
otherMax := float64(0)
for _, score := range allScores {
if score.Type == model.ConstitutionPinghe {
pingheScore = score.Score
} else if score.Score > otherMax {
otherMax = score.Score
}
}
if pingheScore >= 60 && otherMax < 30 {
// 判定为平和质
for _, score := range allScores {
if score.Type == model.ConstitutionPinghe {
primary = score
break
}
}
} else {
// 判定为偏颇体质
for _, score := range allScores {
if score.Type == model.ConstitutionPinghe {
continue
}
if primary.Type == "" && score.Score >= 40 {
primary = score
} else if score.Score >= 30 {
secondary = append(secondary, score)
}
}
// 如果没有≥40的,取最高分
if primary.Type == "" && len(allScores) > 0 {
for _, score := range allScores {
if score.Type != model.ConstitutionPinghe {
primary = score
break
}
}
}
}
// 获取调养建议
recommendations := make(map[string]map[string]string)
recommendations[primary.Type] = model.ConstitutionRecommendations[primary.Type]
for _, sec := range secondary {
recommendations[sec.Type] = model.ConstitutionRecommendations[sec.Type]
}
// 保存评估结果
scoresJSON, _ := json.Marshal(allScores)
secondaryJSON, _ := json.Marshal(secondary)
recsJSON, _ := json.Marshal(recommendations)
assessment := &model.ConstitutionAssessment{
UserID: userID,
AssessedAt: time.Now(),
Scores: string(scoresJSON),
PrimaryConstitution: primary.Type,
SecondaryConstitutions: string(secondaryJSON),
Recommendations: string(recsJSON),
}
if err := s.repo.CreateAssessment(assessment); err != nil {
return nil, err
}
return &AssessmentResult{
PrimaryConstitution: primary,
SecondaryConstitutions: secondary,
AllScores: allScores,
Recommendations: recommendations,
AssessedAt: assessment.AssessedAt,
}, nil
}
func (s *ConstitutionService) GetLatestResult(userID uint) (*AssessmentResult, error) {
assessment, err := s.repo.GetLatestAssessment(userID)
if err != nil {
return nil, err
}
var allScores []ConstitutionScore
var secondary []ConstitutionScore
var recommendations map[string]map[string]string
json.Unmarshal([]byte(assessment.Scores), &allScores)
json.Unmarshal([]byte(assessment.SecondaryConstitutions), &secondary)
json.Unmarshal([]byte(assessment.Recommendations), &recommendations)
var primary ConstitutionScore
for _, score := range allScores {
if score.Type == assessment.PrimaryConstitution {
primary = score
break
}
}
return &AssessmentResult{
PrimaryConstitution: primary,
SecondaryConstitutions: secondary,
AllScores: allScores,
Recommendations: recommendations,
AssessedAt: assessment.AssessedAt,
}, nil
}
func (s *ConstitutionService) GetHistory(userID uint) ([]model.ConstitutionAssessment, error) {
return s.repo.GetAssessmentHistory(userID)
}
```
### 步骤 5:创建体质 Handler 和更新路由
创建 Handler 并在 router.go 中注册路由。
---
## API 接口说明
### GET /api/constitution/questions
获取体质问卷题目
### POST /api/constitution/submit
提交问卷答案,返回体质辨识结果
### GET /api/constitution/result
获取最新体质辨识结果
### GET /api/constitution/history
获取体质测评历史
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `internal/model/constitution_const.go` | 体质常量定义 |
| `internal/database/seed.go` | 问卷题库初始化 |
| `internal/repository/impl/constitution.go` | 体质 Repository |
| `internal/service/constitution.go` | 体质计算 Service |
| `internal/api/handler/constitution.go` | 体质 Handler |
---
## 验收标准
- [ ] 问卷题库自动初始化(67题)
- [ ] 获取问卷接口返回所有题目
- [ ] 提交答案后正确计算体质得分
- [ ] 体质判定逻辑正确(平和质特殊判定)
- [ ] 调养建议正确返回
---
## 预计耗时
40-50 分钟
---
## 下一步
完成后进入 `02-后端开发/06-AI对话模块.md`

833
TODOS/02-后端开发/06-AI对话模块.md

@ -0,0 +1,833 @@
# 06-AI对话模块
## 目标
实现 AI 健康问诊对话功能,支持多轮对话、结合用户体质信息、流式响应。
---
## 前置要求
- 体质辨识模块已完成
- 已有 AI API Key(OpenAI / 通义千问)
---
## 实施步骤
### 步骤 1:创建 AI 客户端抽象
创建 `server/internal/service/ai/client.go`
```go
package ai
import (
"context"
"io"
)
// AIClient AI 客户端接口
type AIClient interface {
Chat(ctx context.Context, messages []Message) (string, error)
ChatStream(ctx context.Context, messages []Message, writer io.Writer) error
}
// Message 对话消息
type Message struct {
Role string `json:"role"` // system, user, assistant
Content string `json:"content"`
}
// Config AI 配置
type Config struct {
Provider string
APIKey string
BaseURL string
Model string
}
```
### 步骤 2:实现 OpenAI 客户端
创建 `server/internal/service/ai/openai.go`
```go
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type OpenAIClient struct {
apiKey string
baseURL string
model string
}
func NewOpenAIClient(cfg *Config) *OpenAIClient {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
model := cfg.Model
if model == "" {
model = "gpt-3.5-turbo"
}
return &OpenAIClient{
apiKey: cfg.APIKey,
baseURL: baseURL,
model: model,
}
}
type openAIRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type openAIResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
func (c *OpenAIClient) Chat(ctx context.Context, messages []Message) (string, error) {
reqBody := openAIRequest{
Model: c.model,
Messages: messages,
Stream: false,
}
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result openAIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("no response from AI")
}
return result.Choices[0].Message.Content, nil
}
func (c *OpenAIClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error {
reqBody := openAIRequest{
Model: c.model,
Messages: messages,
Stream: true,
}
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取 SSE 流
reader := resp.Body
buf := make([]byte, 1024)
for {
n, err := reader.Read(buf)
if err == io.EOF {
break
}
if err != nil {
return err
}
writer.Write(buf[:n])
}
return nil
}
```
### 步骤 2.5:实现阿里云通义千问客户端
创建 `server/internal/service/ai/aliyun.go`
```go
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
const AliyunBaseURL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
type AliyunClient struct {
apiKey string
model string
}
func NewAliyunClient(cfg *Config) *AliyunClient {
model := cfg.Model
if model == "" {
model = "qwen-turbo"
}
return &AliyunClient{
apiKey: cfg.APIKey,
model: model,
}
}
type aliyunRequest struct {
Model string `json:"model"`
Input struct {
Messages []Message `json:"messages"`
} `json:"input"`
Parameters struct {
ResultFormat string `json:"result_format"`
MaxTokens int `json:"max_tokens,omitempty"`
} `json:"parameters"`
}
type aliyunResponse struct {
Output struct {
Text string `json:"text"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
} `json:"output"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
Code string `json:"code"`
Message string `json:"message"`
}
func (c *AliyunClient) Chat(ctx context.Context, messages []Message) (string, error) {
reqBody := aliyunRequest{
Model: c.model,
}
reqBody.Input.Messages = messages
reqBody.Parameters.ResultFormat = "message"
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result aliyunResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Code != "" {
return "", fmt.Errorf("aliyun API error: %s - %s", result.Code, result.Message)
}
// 兼容两种返回格式
if len(result.Output.Choices) > 0 {
return result.Output.Choices[0].Message.Content, nil
}
if result.Output.Text != "" {
return result.Output.Text, nil
}
return "", fmt.Errorf("no response from AI")
}
func (c *AliyunClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error {
reqBody := aliyunRequest{
Model: c.model,
}
reqBody.Input.Messages = messages
reqBody.Parameters.ResultFormat = "message"
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("X-DashScope-SSE", "enable") // 启用流式输出
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取 SSE 流
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
if err == io.EOF {
break
}
if err != nil {
return err
}
writer.Write(buf[:n])
}
return nil
}
```
### 步骤 2.6:创建 AI 客户端工厂
创建 `server/internal/service/ai/factory.go`
```go
package ai
import "health-ai/internal/config"
// NewAIClient 根据配置创建 AI 客户端
func NewAIClient(cfg *config.AIConfig) AIClient {
switch cfg.Provider {
case "aliyun":
return NewAliyunClient(&Config{
APIKey: cfg.Aliyun.APIKey,
Model: cfg.Aliyun.Model,
})
case "openai":
fallthrough
default:
return NewOpenAIClient(&Config{
APIKey: cfg.OpenAI.APIKey,
BaseURL: cfg.OpenAI.BaseURL,
Model: cfg.OpenAI.Model,
})
}
}
```
### 步骤 3:创建对话 Repository
创建 `server/internal/repository/impl/conversation.go`
```go
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type ConversationRepository struct{}
func NewConversationRepository() *ConversationRepository {
return &ConversationRepository{}
}
func (r *ConversationRepository) Create(conv *model.Conversation) error {
return database.DB.Create(conv).Error
}
func (r *ConversationRepository) GetByID(id uint) (*model.Conversation, error) {
var conv model.Conversation
err := database.DB.Preload("Messages").First(&conv, id).Error
return &conv, err
}
func (r *ConversationRepository) GetByUserID(userID uint) ([]model.Conversation, error) {
var convs []model.Conversation
err := database.DB.Where("user_id = ?", userID).Order("updated_at DESC").Find(&convs).Error
return convs, err
}
func (r *ConversationRepository) Delete(id uint) error {
// 先删除消息
database.DB.Where("conversation_id = ?", id).Delete(&model.Message{})
return database.DB.Delete(&model.Conversation{}, id).Error
}
func (r *ConversationRepository) AddMessage(msg *model.Message) error {
return database.DB.Create(msg).Error
}
func (r *ConversationRepository) GetMessages(convID uint) ([]model.Message, error) {
var messages []model.Message
err := database.DB.Where("conversation_id = ?", convID).Order("created_at ASC").Find(&messages).Error
return messages, err
}
func (r *ConversationRepository) UpdateTitle(id uint, title string) error {
return database.DB.Model(&model.Conversation{}).Where("id = ?", id).Update("title", title).Error
}
```
### 步骤 4:创建对话 Service
创建 `server/internal/service/conversation.go`
```go
package service
import (
"context"
"fmt"
"io"
"time"
"health-ai/internal/config"
"health-ai/internal/model"
"health-ai/internal/repository/impl"
"health-ai/internal/service/ai"
)
type ConversationService struct {
convRepo *impl.ConversationRepository
constitutionRepo *impl.ConstitutionRepository
healthRepo *impl.HealthRepository
aiClient ai.AIClient
}
func NewConversationService() *ConversationService {
// 使用工厂方法创建 AI 客户端
client := ai.NewAIClient(&config.AppConfig.AI)
return &ConversationService{
convRepo: impl.NewConversationRepository(),
constitutionRepo: impl.NewConstitutionRepository(),
healthRepo: impl.NewHealthRepository(),
aiClient: client,
}
}
// 获取用户对话列表
func (s *ConversationService) GetConversations(userID uint) ([]model.Conversation, error) {
return s.convRepo.GetByUserID(userID)
}
// 创建新对话
func (s *ConversationService) CreateConversation(userID uint, title string) (*model.Conversation, error) {
if title == "" {
title = "新对话 " + time.Now().Format("01-02 15:04")
}
conv := &model.Conversation{
UserID: userID,
Title: title,
}
if err := s.convRepo.Create(conv); err != nil {
return nil, err
}
return conv, nil
}
// 获取对话详情
func (s *ConversationService) GetConversation(id uint) (*model.Conversation, error) {
return s.convRepo.GetByID(id)
}
// 删除对话
func (s *ConversationService) DeleteConversation(id uint) error {
return s.convRepo.Delete(id)
}
// 发送消息
func (s *ConversationService) SendMessage(ctx context.Context, userID uint, convID uint, content string) (string, error) {
// 保存用户消息
userMsg := &model.Message{
ConversationID: convID,
Role: "user",
Content: content,
}
if err := s.convRepo.AddMessage(userMsg); err != nil {
return "", err
}
// 构建对话上下文
messages := s.buildMessages(userID, convID, content)
// 调用 AI
response, err := s.aiClient.Chat(ctx, messages)
if err != nil {
return "", err
}
// 保存 AI 回复
assistantMsg := &model.Message{
ConversationID: convID,
Role: "assistant",
Content: response,
}
if err := s.convRepo.AddMessage(assistantMsg); err != nil {
return "", err
}
return response, nil
}
// 流式发送消息
func (s *ConversationService) SendMessageStream(ctx context.Context, userID uint, convID uint, content string, writer io.Writer) error {
// 保存用户消息
userMsg := &model.Message{
ConversationID: convID,
Role: "user",
Content: content,
}
if err := s.convRepo.AddMessage(userMsg); err != nil {
return err
}
// 构建对话上下文
messages := s.buildMessages(userID, convID, content)
// 调用 AI 流式接口
return s.aiClient.ChatStream(ctx, messages, writer)
}
// 构建消息上下文
func (s *ConversationService) buildMessages(userID uint, convID uint, currentMsg string) []ai.Message {
messages := []ai.Message{}
// 系统提示词
systemPrompt := s.buildSystemPrompt(userID)
messages = append(messages, ai.Message{
Role: "system",
Content: systemPrompt,
})
// 历史消息(限制数量避免超出 token 限制)
historyMsgs, _ := s.convRepo.GetMessages(convID)
// 限制历史消息数量
maxHistory := config.AppConfig.AI.MaxHistoryMessages
if maxHistory <= 0 {
maxHistory = 10 // 默认10条
}
if len(historyMsgs) > maxHistory {
historyMsgs = historyMsgs[len(historyMsgs)-maxHistory:]
}
for _, msg := range historyMsgs {
messages = append(messages, ai.Message{
Role: msg.Role,
Content: msg.Content,
})
}
return messages
}
// 系统提示词模板(完整版见 design.md 4.3.1 节)
const systemPromptTemplate = `# 角色定义
你是"健康AI助手",一个专业的健康咨询助理。你基于中医体质辨识理论,为用户提供个性化的健康建议。
## 重要声明
- 你不是专业医师,仅提供健康咨询和养生建议
- 你的建议不能替代医生的诊断和治疗
- 遇到以下情况,必须立即建议用户就医:
* 胸痛、呼吸困难、剧烈头痛
* 高烧不退(超过39°C持续24小时)
* 意识模糊、晕厥
* 严重外伤、大量出血
* 持续剧烈腹痛
* 疑似中风症状(口眼歪斜、肢体无力、言语不清)
## 用户信息
%s
## 用户体质
%s
## 用药历史
%s
## 回答原则
1. 回答控制在200字以内,简洁明了
2. 根据用户体质给出针对性建议
3. 用药建议优先推荐非处方中成药或食疗,注明"建议咨询药师"
4. 不推荐处方药,不做疾病诊断
## 回答格式
【情况分析】一句话概括
【建议】
1. 具体建议
【用药参考】(如适用)
- 药品名称:用法(建议咨询药师)
【提醒】注意事项或就医建议`
// 构建系统提示词(包含用户体质信息)
func (s *ConversationService) buildSystemPrompt(userID uint) string {
var userProfile, constitutionInfo, medicationHistory string
// 获取用户基本信息
profile, err := s.healthRepo.GetProfileByUserID(userID)
if err == nil && profile.ID > 0 {
age := calculateAge(profile.BirthDate)
gender := "未知"
if profile.Gender == "male" {
gender = "男"
} else if profile.Gender == "female" {
gender = "女"
}
userProfile = fmt.Sprintf("性别:%s,年龄:%d岁,BMI:%.1f", gender, age, profile.BMI)
} else {
userProfile = "暂无"
}
// 获取用户体质信息
constitution, err := s.constitutionRepo.GetLatestAssessment(userID)
if err == nil && constitution.ID > 0 {
constitutionName := model.ConstitutionNames[constitution.PrimaryConstitution]
description := model.ConstitutionDescriptions[constitution.PrimaryConstitution]
constitutionInfo = fmt.Sprintf("主体质:%s\n特征:%s", constitutionName, description)
} else {
constitutionInfo = "暂未测评"
}
// 获取用药历史
medications, err := s.healthRepo.GetMedications(userID)
if err == nil && len(medications) > 0 {
var medNames []string
for _, m := range medications {
medNames = append(medNames, m.Name)
}
medicationHistory = "近期用药:" + strings.Join(medNames, "、")
} else {
medicationHistory = "暂无记录"
}
return fmt.Sprintf(systemPromptTemplate, userProfile, constitutionInfo, medicationHistory)
// 获取用户健康档案
profile, err := s.healthRepo.GetProfileByUserID(userID)
if err == nil && profile.ID > 0 {
basePrompt += fmt.Sprintf(`
用户基本信息:
- 性别:%s
- BMI:%.1f`, profile.Gender, profile.BMI)
}
return basePrompt
}
```
### 步骤 5:创建对话 Handler
创建 `server/internal/api/handler/conversation.go`
```go
package handler
import (
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type ConversationHandler struct {
convService *service.ConversationService
}
func NewConversationHandler() *ConversationHandler {
return &ConversationHandler{
convService: service.NewConversationService(),
}
}
func (h *ConversationHandler) GetConversations(c *gin.Context) {
userID := middleware.GetUserID(c)
convs, err := h.convService.GetConversations(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, convs)
}
func (h *ConversationHandler) CreateConversation(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct {
Title string `json:"title"`
}
c.ShouldBindJSON(&req)
conv, err := h.convService.CreateConversation(userID, req.Title)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, conv)
}
func (h *ConversationHandler) GetConversation(c *gin.Context) {
id := c.Param("id")
var convID uint
fmt.Sscanf(id, "%d", &convID)
conv, err := h.convService.GetConversation(convID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, conv)
}
func (h *ConversationHandler) DeleteConversation(c *gin.Context) {
id := c.Param("id")
var convID uint
fmt.Sscanf(id, "%d", &convID)
if err := h.convService.DeleteConversation(convID); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
type SendMessageRequest struct {
Content string `json:"content" binding:"required"`
}
func (h *ConversationHandler) SendMessage(c *gin.Context) {
userID := middleware.GetUserID(c)
id := c.Param("id")
var convID uint
fmt.Sscanf(id, "%d", &convID)
var req SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
reply, err := h.convService.SendMessage(c.Request.Context(), userID, convID, req.Content)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, gin.H{
"reply": reply,
})
}
```
### 步骤 6:更新路由
`router.go` 中添加对话路由。
---
## API 接口说明
### GET /api/conversations
获取对话列表
### POST /api/conversations
创建新对话
### GET /api/conversations/:id
获取对话详情(含消息)
### DELETE /api/conversations/:id
删除对话
### POST /api/conversations/:id/messages
发送消息并获取 AI 回复
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `internal/service/ai/client.go` | AI 客户端接口定义 |
| `internal/service/ai/openai.go` | OpenAI 客户端实现 |
| `internal/service/ai/aliyun.go` | 阿里云通义千问客户端实现 |
| `internal/service/ai/factory.go` | AI 客户端工厂 |
| `internal/repository/impl/conversation.go` | 对话 Repository |
| `internal/service/conversation.go` | 对话 Service |
| `internal/api/handler/conversation.go` | 对话 Handler |
---
## AI 服务配置说明
`config.yaml` 中配置 AI 服务:
```yaml
ai:
provider: aliyun # 可选: openai, aliyun
max_history_messages: 10 # 最大历史消息数
openai:
api_key: "sk-xxx" # OpenAI API Key
base_url: "https://api.openai.com/v1"
model: "gpt-3.5-turbo"
aliyun:
api_key: "sk-xxx" # 阿里云 DashScope API Key
model: "qwen-turbo" # 可选: qwen-turbo, qwen-plus, qwen-max
```
---
## 验收标准
- [ ] 创建/获取/删除对话正常
- [ ] 发送消息返回 AI 回复
- [ ] AI 回复结合用户体质和用药历史
- [ ] 对话历史正确保存(限制消息数量)
- [ ] 支持 OpenAI 和阿里云通义千问切换
- [ ] 无 API Key 时给出友好提示
- [ ] 紧急情况提示用户就医
---
## 预计耗时
40-50 分钟
---
## 下一步
完成后进入 `02-后端开发/07-健康档案模块.md`

512
TODOS/02-后端开发/07-健康档案模块.md

@ -0,0 +1,512 @@
# 07-健康档案模块
## 目标
实现用户健康档案的查询和管理功能,提供完整的健康信息视图。
---
## 前置要求
- 健康调查模块已完成
- 体质辨识模块已完成
---
## 实施步骤
### 步骤 1:创建用户 Service
创建 `server/internal/service/user.go`
```go
package service
import (
"health-ai/internal/model"
"health-ai/internal/repository/impl"
)
type UserService struct {
userRepo *impl.UserRepositoryImpl
healthRepo *impl.HealthRepository
constitutionRepo *impl.ConstitutionRepository
}
func NewUserService() *UserService {
return &UserService{
userRepo: impl.NewUserRepository(),
healthRepo: impl.NewHealthRepository(),
constitutionRepo: impl.NewConstitutionRepository(),
}
}
// 用户资料响应
type UserProfileResponse struct {
ID uint `json:"id"`
Phone string `json:"phone"`
Email string `json:"email"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
SurveyCompleted bool `json:"survey_completed"`
}
// 获取用户资料
func (s *UserService) GetProfile(userID uint) (*UserProfileResponse, error) {
user, err := s.userRepo.GetByID(userID)
if err != nil {
return nil, err
}
return &UserProfileResponse{
ID: user.ID,
Phone: user.Phone,
Email: user.Email,
Nickname: user.Nickname,
Avatar: user.Avatar,
SurveyCompleted: user.SurveyCompleted,
}, nil
}
// 更新用户资料请求
type UpdateProfileRequest struct {
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Email string `json:"email"`
}
// 更新用户资料
func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileRequest) error {
user, err := s.userRepo.GetByID(userID)
if err != nil {
return err
}
if req.Nickname != "" {
user.Nickname = req.Nickname
}
if req.Avatar != "" {
user.Avatar = req.Avatar
}
if req.Email != "" {
user.Email = req.Email
}
return s.userRepo.Update(user)
}
// 完整健康档案响应
type FullHealthProfileResponse struct {
BasicInfo *model.HealthProfile `json:"basic_info"`
Lifestyle *model.LifestyleInfo `json:"lifestyle"`
MedicalHistory []model.MedicalHistory `json:"medical_history"`
FamilyHistory []model.FamilyHistory `json:"family_history"`
AllergyRecords []model.AllergyRecord `json:"allergy_records"`
Constitution *ConstitutionSummary `json:"constitution"`
}
type ConstitutionSummary struct {
PrimaryType string `json:"primary_type"`
PrimaryName string `json:"primary_name"`
PrimaryDescription string `json:"primary_description"`
AssessedAt string `json:"assessed_at"`
}
// 获取完整健康档案
func (s *UserService) GetFullHealthProfile(userID uint) (*FullHealthProfileResponse, error) {
response := &FullHealthProfileResponse{}
// 基础信息
profile, err := s.healthRepo.GetProfileByUserID(userID)
if err == nil && profile.ID > 0 {
response.BasicInfo = profile
// 病史
histories, _ := s.healthRepo.GetMedicalHistories(profile.ID)
response.MedicalHistory = histories
// 家族史
familyHistories, _ := s.healthRepo.GetFamilyHistories(profile.ID)
response.FamilyHistory = familyHistories
// 过敏记录
allergies, _ := s.healthRepo.GetAllergyRecords(profile.ID)
response.AllergyRecords = allergies
}
// 生活习惯
lifestyle, err := s.healthRepo.GetLifestyleByUserID(userID)
if err == nil && lifestyle.ID > 0 {
response.Lifestyle = lifestyle
}
// 体质信息
constitution, err := s.constitutionRepo.GetLatestAssessment(userID)
if err == nil && constitution.ID > 0 {
response.Constitution = &ConstitutionSummary{
PrimaryType: constitution.PrimaryConstitution,
PrimaryName: model.ConstitutionNames[constitution.PrimaryConstitution],
PrimaryDescription: model.ConstitutionDescriptions[constitution.PrimaryConstitution],
AssessedAt: constitution.AssessedAt.Format("2006-01-02"),
}
}
return response, nil
}
// 更新健康档案基础信息
func (s *UserService) UpdateHealthProfile(userID uint, req *BasicInfoRequest) error {
return NewSurveyService().SubmitBasicInfo(userID, req)
}
// 更新生活习惯
func (s *UserService) UpdateLifestyle(userID uint, req *LifestyleRequest) error {
return NewSurveyService().SubmitLifestyle(userID, req)
}
```
### 步骤 2:创建用户 Handler
创建 `server/internal/api/handler/user.go`
```go
package handler
import (
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
userService *service.UserService
}
func NewUserHandler() *UserHandler {
return &UserHandler{
userService: service.NewUserService(),
}
}
// 获取用户资料
func (h *UserHandler) GetProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
profile, err := h.userService.GetProfile(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, profile)
}
// 更新用户资料
func (h *UserHandler) UpdateProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.userService.UpdateProfile(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
// 获取完整健康档案
func (h *UserHandler) GetHealthProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
profile, err := h.userService.GetFullHealthProfile(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, profile)
}
// 更新健康档案基础信息
func (h *UserHandler) UpdateHealthProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.BasicInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.userService.UpdateHealthProfile(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
// 获取生活习惯
func (h *UserHandler) GetLifestyle(c *gin.Context) {
userID := middleware.GetUserID(c)
profile, err := h.userService.GetFullHealthProfile(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, profile.Lifestyle)
}
// 更新生活习惯
func (h *UserHandler) UpdateLifestyle(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.LifestyleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误: "+err.Error())
return
}
if err := h.userService.UpdateLifestyle(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
```
### 步骤 3:更新完整路由配置
更新 `server/internal/api/router.go`
```go
package api
import (
"health-ai/internal/api/handler"
"health-ai/internal/api/middleware"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func SetupRouter(mode string) *gin.Engine {
gin.SetMode(mode)
r := gin.Default()
// 跨域配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
}))
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// API 路由组
api := r.Group("/api")
{
// ========== 公开接口(无需登录)==========
authHandler := handler.NewAuthHandler()
auth := api.Group("/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// ========== 需要认证的接口 ==========
authorized := api.Group("")
authorized.Use(middleware.AuthRequired())
{
// 用户接口
userHandler := handler.NewUserHandler()
user := authorized.Group("/user")
{
user.GET("/profile", userHandler.GetProfile)
user.PUT("/profile", userHandler.UpdateProfile)
user.GET("/health-profile", userHandler.GetHealthProfile)
user.PUT("/health-profile", userHandler.UpdateHealthProfile)
user.GET("/lifestyle", userHandler.GetLifestyle)
user.PUT("/lifestyle", userHandler.UpdateLifestyle)
}
// 健康调查接口
surveyHandler := handler.NewSurveyHandler()
survey := authorized.Group("/survey")
{
survey.GET("/status", surveyHandler.GetStatus)
survey.POST("/basic-info", surveyHandler.SubmitBasicInfo)
survey.POST("/lifestyle", surveyHandler.SubmitLifestyle)
survey.POST("/medical-history", surveyHandler.SubmitMedicalHistory)
survey.POST("/family-history", surveyHandler.SubmitFamilyHistory)
survey.POST("/allergy", surveyHandler.SubmitAllergy)
}
// 体质辨识接口
constitutionHandler := handler.NewConstitutionHandler()
constitution := authorized.Group("/constitution")
{
constitution.GET("/questions", constitutionHandler.GetQuestions)
constitution.POST("/submit", constitutionHandler.Submit)
constitution.GET("/result", constitutionHandler.GetResult)
constitution.GET("/history", constitutionHandler.GetHistory)
constitution.GET("/recommendations", constitutionHandler.GetRecommendations)
}
// 对话接口
convHandler := handler.NewConversationHandler()
conversations := authorized.Group("/conversations")
{
conversations.GET("", convHandler.GetConversations)
conversations.POST("", convHandler.CreateConversation)
conversations.GET("/:id", convHandler.GetConversation)
conversations.DELETE("/:id", convHandler.DeleteConversation)
conversations.POST("/:id/messages", convHandler.SendMessage)
}
}
}
return r
}
```
### 步骤 4:更新主程序完整版
更新 `server/cmd/server/main.go`
```go
package main
import (
"fmt"
"log"
"health-ai/internal/api"
"health-ai/internal/config"
"health-ai/internal/database"
"health-ai/internal/model"
"health-ai/pkg/jwt"
)
func main() {
// 加载配置
if err := config.LoadConfig("config.yaml"); err != nil {
log.Fatalf("Failed to load config: %v", err)
}
log.Println("✓ Config loaded")
// 初始化数据库
if err := database.InitDatabase(&config.AppConfig.Database); err != nil {
log.Fatalf("Failed to init database: %v", err)
}
log.Println("✓ Database connected")
// 自动迁移
if err := database.AutoMigrate(model.AllModels()...); err != nil {
log.Fatalf("Failed to migrate: %v", err)
}
log.Println("✓ Database migrated")
// 初始化问卷题库
if err := database.SeedQuestionBank(); err != nil {
log.Fatalf("Failed to seed question bank: %v", err)
}
log.Println("✓ Question bank seeded")
// 初始化 JWT
jwt.Init(config.AppConfig.JWT.Secret, config.AppConfig.JWT.ExpireHours)
log.Println("✓ JWT initialized")
// 启动服务器
router := api.SetupRouter(config.AppConfig.Server.Mode)
addr := fmt.Sprintf(":%d", config.AppConfig.Server.Port)
log.Printf("✓ Server running on http://localhost%s", addr)
log.Println("========================================")
log.Println("Health AI Server Ready!")
log.Println("========================================")
if err := router.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
```
---
## API 接口说明
### GET /api/user/profile
获取用户基本资料
### PUT /api/user/profile
更新用户基本资料(昵称、头像、邮箱)
### GET /api/user/health-profile
获取完整健康档案(含基础信息、生活习惯、病史、体质)
### PUT /api/user/health-profile
更新健康档案基础信息
### GET /api/user/lifestyle
获取生活习惯信息
### PUT /api/user/lifestyle
更新生活习惯信息
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `internal/service/user.go` | 用户 Service |
| `internal/api/handler/user.go` | 用户 Handler |
| 更新 `internal/api/router.go` | 完整路由配置 |
| 更新 `cmd/server/main.go` | 完整主程序 |
---
## 完整 API 列表汇总
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| GET | /health | 健康检查 | 否 |
| POST | /api/auth/register | 用户注册 | 否 |
| POST | /api/auth/login | 用户登录 | 否 |
| GET | /api/user/profile | 获取用户资料 | 是 |
| PUT | /api/user/profile | 更新用户资料 | 是 |
| GET | /api/user/health-profile | 获取健康档案 | 是 |
| PUT | /api/user/health-profile | 更新健康档案 | 是 |
| GET | /api/user/lifestyle | 获取生活习惯 | 是 |
| PUT | /api/user/lifestyle | 更新生活习惯 | 是 |
| GET | /api/survey/status | 获取调查状态 | 是 |
| POST | /api/survey/basic-info | 提交基础信息 | 是 |
| POST | /api/survey/lifestyle | 提交生活习惯 | 是 |
| POST | /api/survey/medical-history | 提交病史 | 是 |
| POST | /api/survey/family-history | 提交家族史 | 是 |
| POST | /api/survey/allergy | 提交过敏信息 | 是 |
| GET | /api/constitution/questions | 获取问卷题目 | 是 |
| POST | /api/constitution/submit | 提交问卷 | 是 |
| GET | /api/constitution/result | 获取体质结果 | 是 |
| GET | /api/constitution/history | 获取测评历史 | 是 |
| GET | /api/conversations | 获取对话列表 | 是 |
| POST | /api/conversations | 创建对话 | 是 |
| GET | /api/conversations/:id | 获取对话详情 | 是 |
| DELETE | /api/conversations/:id | 删除对话 | 是 |
| POST | /api/conversations/:id/messages | 发送消息 | 是 |
---
## 验收标准
- [ ] 获取用户资料正常
- [ ] 获取完整健康档案正常
- [ ] 更新资料和生活习惯正常
- [ ] 所有 API 接口可正常调用
- [ ] 服务器启动日志完整
---
## 预计耗时
30-40 分钟
---
## 下一步
后端开发完成!进入 `03-Web前端开发/01-项目结构初始化.md`

673
TODOS/02-后端开发/08-保健品商城关联模块.md

@ -0,0 +1,673 @@
# 08-保健品商城关联模块
## 目标
实现保健品数据管理和 AI 问诊时的产品推荐功能,关联外部保健品商城。
---
## 前置要求
- AI 对话模块已完成
- 体质辨识模块已完成
---
## 实施步骤
### 步骤 1:创建产品数据模型
创建 `server/internal/model/product.go`
```go
package model
import "time"
// Product 保健品
type Product struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
Category string `gorm:"size:50" json:"category"`
Description string `gorm:"type:text" json:"description"`
Efficacy string `gorm:"type:text" json:"efficacy"`
Suitable string `gorm:"type:text" json:"suitable"`
Price float64 `gorm:"type:decimal(10,2)" json:"price"`
ImageURL string `gorm:"size:255" json:"image_url"`
MallURL string `gorm:"size:255" json:"mall_url"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
// ConstitutionProduct 体质-产品关联
type ConstitutionProduct struct {
ID uint `gorm:"primaryKey" json:"id"`
ConstitutionType string `gorm:"size:20;not null;index" json:"constitution_type"`
ProductID uint `gorm:"not null;index" json:"product_id"`
Priority int `gorm:"default:0" json:"priority"`
Reason string `gorm:"size:200" json:"reason"`
}
// SymptomProduct 症状-产品关联
type SymptomProduct struct {
ID uint `gorm:"primaryKey" json:"id"`
Keyword string `gorm:"size:50;not null;index" json:"keyword"`
ProductID uint `gorm:"not null;index" json:"product_id"`
Priority int `gorm:"default:0" json:"priority"`
}
// 产品分类常量
const (
CategoryBuqi = "补气类"
CategoryWenyang = "温阳类"
CategoryZiyin = "滋阴类"
CategoryQushi = "祛湿类"
CategoryHuoxue = "活血类"
CategoryLiqi = "理气类"
CategoryKangmin = "抗敏类"
CategoryXinnao = "心脑血管类"
CategoryGuguanjie = "骨关节类"
CategoryXuetang = "血糖调节类"
CategoryZhumian = "助眠安神类"
CategoryJiannao = "健脑益智类"
CategoryRunchang = "润肠通便类"
CategoryHuyan = "护眼明目类"
CategoryMianyi = "增强免疫类"
CategoryZonghe = "综合类"
)
```
### 步骤 2:创建种子数据
创建 `server/internal/database/seed_products.go`
```go
package database
import (
"health-ai/internal/model"
"log"
)
// SeedProducts 初始化保健品模拟数据
func SeedProducts() {
var count int64
DB.Model(&model.Product{}).Count(&count)
if count > 0 {
log.Println("产品数据已存在,跳过初始化")
return
}
products := getProductSeeds()
for _, p := range products {
DB.Create(&p)
}
log.Printf("已初始化 %d 条产品数据", len(products))
// 初始化体质-产品关联
constitutionProducts := getConstitutionProductSeeds()
for _, cp := range constitutionProducts {
DB.Create(&cp)
}
log.Printf("已初始化 %d 条体质-产品关联", len(constitutionProducts))
// 初始化症状-产品关联
symptomProducts := getSymptomProductSeeds()
for _, sp := range symptomProducts {
DB.Create(&sp)
}
log.Printf("已初始化 %d 条症状-产品关联", len(symptomProducts))
}
func getProductSeeds() []model.Product {
return []model.Product{
// ========== 体质调养类 ==========
// 补气类
{ID: 1, Name: "黄芪精口服液", Category: "补气类",
Efficacy: "补气固表,增强免疫力",
Suitable: "气虚质、易疲劳人群",
Price: 68.00, MallURL: "https://mall.example.com/product/1", IsActive: true},
{ID: 2, Name: "人参蜂王浆", Category: "补气类",
Efficacy: "补气养血,改善疲劳",
Suitable: "气虚质、体力不足人群",
Price: 128.00, MallURL: "https://mall.example.com/product/2", IsActive: true},
{ID: 3, Name: "西洋参含片", Category: "补气类",
Efficacy: "益气养阴,清热生津",
Suitable: "气虚质、气阴两虚人群",
Price: 98.00, MallURL: "https://mall.example.com/product/3", IsActive: true},
// 温阳类
{ID: 4, Name: "鹿茸参精胶囊", Category: "温阳类",
Efficacy: "温肾壮阳,补气养血",
Suitable: "阳虚质、畏寒怕冷人群",
Price: 268.00, MallURL: "https://mall.example.com/product/4", IsActive: true},
{ID: 5, Name: "桂圆红枣茶", Category: "温阳类",
Efficacy: "温中补血,养心安神",
Suitable: "阳虚质、手脚冰凉人群",
Price: 45.00, MallURL: "https://mall.example.com/product/5", IsActive: true},
// 滋阴类
{ID: 6, Name: "枸杞原浆", Category: "滋阴类",
Efficacy: "滋补肝肾,明目润肺",
Suitable: "阴虚质、眼睛干涩人群",
Price: 158.00, MallURL: "https://mall.example.com/product/6", IsActive: true},
{ID: 7, Name: "即食燕窝", Category: "滋阴类",
Efficacy: "滋阴润肺,美容养颜",
Suitable: "阴虚质、皮肤干燥人群",
Price: 398.00, MallURL: "https://mall.example.com/product/7", IsActive: true},
{ID: 8, Name: "铁皮石斛粉", Category: "滋阴类",
Efficacy: "滋阴清热,养胃生津",
Suitable: "阴虚质、口干舌燥人群",
Price: 188.00, MallURL: "https://mall.example.com/product/8", IsActive: true},
// 祛湿类
{ID: 9, Name: "红豆薏米粉", Category: "祛湿类",
Efficacy: "健脾祛湿,消肿利水",
Suitable: "痰湿质、湿热质、身体沉重人群",
Price: 39.00, MallURL: "https://mall.example.com/product/9", IsActive: true},
{ID: 10, Name: "茯苓山药糕", Category: "祛湿类",
Efficacy: "健脾益气,祛湿止泻",
Suitable: "痰湿质、脾胃虚弱人群",
Price: 56.00, MallURL: "https://mall.example.com/product/10", IsActive: true},
{ID: 11, Name: "清热祛湿茶", Category: "祛湿类",
Efficacy: "清热利湿,解毒消肿",
Suitable: "湿热质、口苦口干人群",
Price: 35.00, MallURL: "https://mall.example.com/product/11", IsActive: true},
// 活血类
{ID: 12, Name: "三七粉", Category: "活血类",
Efficacy: "活血化瘀,消肿止痛",
Suitable: "血瘀质、面色晦暗人群",
Price: 128.00, MallURL: "https://mall.example.com/product/12", IsActive: true},
{ID: 13, Name: "丹参片", Category: "活血类",
Efficacy: "活血化瘀,通经止痛",
Suitable: "血瘀质、易生斑点人群",
Price: 48.00, MallURL: "https://mall.example.com/product/13", IsActive: true},
// 理气类
{ID: 14, Name: "玫瑰花茶", Category: "理气类",
Efficacy: "疏肝理气,美容养颜",
Suitable: "气郁质、情绪低落人群",
Price: 38.00, MallURL: "https://mall.example.com/product/14", IsActive: true},
{ID: 15, Name: "陈皮普洱茶", Category: "理气类",
Efficacy: "理气健脾,消食化痰",
Suitable: "气郁质、胸闷不适人群",
Price: 68.00, MallURL: "https://mall.example.com/product/15", IsActive: true},
// 抗敏类
{ID: 16, Name: "益生菌粉", Category: "抗敏类",
Efficacy: "调节肠道,增强免疫",
Suitable: "特禀质、过敏体质人群",
Price: 98.00, MallURL: "https://mall.example.com/product/16", IsActive: true},
{ID: 17, Name: "蜂胶软胶囊", Category: "抗敏类",
Efficacy: "抗菌消炎,增强体质",
Suitable: "特禀质、免疫力低下人群",
Price: 168.00, MallURL: "https://mall.example.com/product/17", IsActive: true},
// 综合类
{ID: 18, Name: "阿胶固元糕", Category: "综合类",
Efficacy: "补血养颜,滋阴润燥",
Suitable: "平和质、日常滋补人群",
Price: 88.00, MallURL: "https://mall.example.com/product/18", IsActive: true},
{ID: 19, Name: "蜂蜜", Category: "综合类",
Efficacy: "润肠通便,美容养颜",
Suitable: "各种体质日常调养",
Price: 68.00, MallURL: "https://mall.example.com/product/19", IsActive: true},
{ID: 20, Name: "复合维生素片", Category: "综合类",
Efficacy: "补充营养,增强体质",
Suitable: "各种体质日常补充",
Price: 78.00, MallURL: "https://mall.example.com/product/20", IsActive: true},
// ========== 中老年常见问题类 ==========
// 心脑血管类
{ID: 21, Name: "深海鱼油软胶囊", Category: "心脑血管类",
Efficacy: "辅助降血脂,保护心脑血管",
Suitable: "高血脂、动脉硬化人群",
Price: 128.00, MallURL: "https://mall.example.com/product/21", IsActive: true},
{ID: 22, Name: "纳豆激酶胶囊", Category: "心脑血管类",
Efficacy: "溶解血栓,改善血液循环",
Suitable: "中老年心脑血管亚健康人群",
Price: 198.00, MallURL: "https://mall.example.com/product/22", IsActive: true},
{ID: 23, Name: "大豆卵磷脂", Category: "心脑血管类",
Efficacy: "调节血脂,保护肝脏",
Suitable: "高血脂、脂肪肝人群",
Price: 88.00, MallURL: "https://mall.example.com/product/23", IsActive: true},
// 骨关节类
{ID: 24, Name: "氨糖软骨素钙片", Category: "骨关节类",
Efficacy: "修复软骨,润滑关节,补充钙质",
Suitable: "关节疼痛、骨质疏松人群",
Price: 168.00, MallURL: "https://mall.example.com/product/24", IsActive: true},
{ID: 25, Name: "液体钙维D软胶囊", Category: "骨关节类",
Efficacy: "补钙,促进钙吸收,预防骨质疏松",
Suitable: "中老年人、骨质疏松人群",
Price: 78.00, MallURL: "https://mall.example.com/product/25", IsActive: true},
{ID: 26, Name: "骨胶原蛋白肽", Category: "骨关节类",
Efficacy: "增加骨密度,改善关节灵活性",
Suitable: "骨关节退化人群",
Price: 218.00, MallURL: "https://mall.example.com/product/26", IsActive: true},
// 血糖调节类
{ID: 27, Name: "苦瓜洋参软胶囊", Category: "血糖调节类",
Efficacy: "辅助降血糖,改善糖代谢",
Suitable: "血糖偏高人群",
Price: 138.00, MallURL: "https://mall.example.com/product/27", IsActive: true},
{ID: 28, Name: "桑叶茶", Category: "血糖调节类",
Efficacy: "辅助稳定血糖,清热降火",
Suitable: "血糖偏高、糖尿病人群",
Price: 45.00, MallURL: "https://mall.example.com/product/28", IsActive: true},
// 助眠安神类
{ID: 29, Name: "褪黑素维生素B6片", Category: "助眠安神类",
Efficacy: "改善睡眠,调节生物钟",
Suitable: "失眠、睡眠质量差人群",
Price: 68.00, MallURL: "https://mall.example.com/product/29", IsActive: true},
{ID: 30, Name: "酸枣仁百合膏", Category: "助眠安神类",
Efficacy: "养心安神,改善睡眠",
Suitable: "心烦失眠、多梦易醒人群",
Price: 58.00, MallURL: "https://mall.example.com/product/30", IsActive: true},
// 健脑益智类
{ID: 31, Name: "银杏叶提取物片", Category: "健脑益智类",
Efficacy: "改善记忆力,促进脑部血液循环",
Suitable: "记忆力减退、脑供血不足人群",
Price: 98.00, MallURL: "https://mall.example.com/product/31", IsActive: true},
{ID: 32, Name: "DHA藻油软胶囊", Category: "健脑益智类",
Efficacy: "补充脑营养,改善认知功能",
Suitable: "中老年脑功能下降人群",
Price: 158.00, MallURL: "https://mall.example.com/product/32", IsActive: true},
// 润肠通便类
{ID: 33, Name: "膳食纤维粉", Category: "润肠通便类",
Efficacy: "促进肠道蠕动,改善便秘",
Suitable: "便秘、肠道功能紊乱人群",
Price: 48.00, MallURL: "https://mall.example.com/product/33", IsActive: true},
{ID: 34, Name: "综合酵素原液", Category: "润肠通便类",
Efficacy: "促进消化,排毒通便",
Suitable: "消化不良、便秘人群",
Price: 128.00, MallURL: "https://mall.example.com/product/34", IsActive: true},
// 护眼明目类
{ID: 35, Name: "叶黄素蓝莓护眼片", Category: "护眼明目类",
Efficacy: "保护视网膜,缓解眼疲劳",
Suitable: "视力下降、眼睛干涩人群",
Price: 118.00, MallURL: "https://mall.example.com/product/35", IsActive: true},
// 增强免疫类
{ID: 36, Name: "灵芝孢子粉胶囊", Category: "增强免疫类",
Efficacy: "增强免疫力,抗疲劳",
Suitable: "免疫力低下、体质虚弱人群",
Price: 298.00, MallURL: "https://mall.example.com/product/36", IsActive: true},
}
}
func getConstitutionProductSeeds() []model.ConstitutionProduct {
return []model.ConstitutionProduct{
// 气虚质推荐
{ConstitutionType: "qixu", ProductID: 1, Priority: 1, Reason: "补气固表"},
{ConstitutionType: "qixu", ProductID: 2, Priority: 2, Reason: "补气养血"},
{ConstitutionType: "qixu", ProductID: 3, Priority: 3, Reason: "益气养阴"},
{ConstitutionType: "qixu", ProductID: 36, Priority: 4, Reason: "增强免疫"},
// 阳虚质推荐
{ConstitutionType: "yangxu", ProductID: 4, Priority: 1, Reason: "温肾壮阳"},
{ConstitutionType: "yangxu", ProductID: 5, Priority: 2, Reason: "温中补血"},
// 阴虚质推荐
{ConstitutionType: "yinxu", ProductID: 6, Priority: 1, Reason: "滋补肝肾"},
{ConstitutionType: "yinxu", ProductID: 7, Priority: 2, Reason: "滋阴润肺"},
{ConstitutionType: "yinxu", ProductID: 8, Priority: 3, Reason: "滋阴清热"},
// 痰湿质推荐
{ConstitutionType: "tanshi", ProductID: 9, Priority: 1, Reason: "健脾祛湿"},
{ConstitutionType: "tanshi", ProductID: 10, Priority: 2, Reason: "健脾益气"},
// 湿热质推荐
{ConstitutionType: "shire", ProductID: 11, Priority: 1, Reason: "清热利湿"},
{ConstitutionType: "shire", ProductID: 9, Priority: 2, Reason: "祛湿利水"},
// 血瘀质推荐
{ConstitutionType: "xueyu", ProductID: 12, Priority: 1, Reason: "活血化瘀"},
{ConstitutionType: "xueyu", ProductID: 13, Priority: 2, Reason: "活血通经"},
{ConstitutionType: "xueyu", ProductID: 21, Priority: 3, Reason: "改善血液循环"},
// 气郁质推荐
{ConstitutionType: "qiyu", ProductID: 14, Priority: 1, Reason: "疏肝理气"},
{ConstitutionType: "qiyu", ProductID: 15, Priority: 2, Reason: "理气健脾"},
{ConstitutionType: "qiyu", ProductID: 30, Priority: 3, Reason: "安神助眠"},
// 特禀质推荐
{ConstitutionType: "tebing", ProductID: 16, Priority: 1, Reason: "调节免疫"},
{ConstitutionType: "tebing", ProductID: 17, Priority: 2, Reason: "增强体质"},
// 平和质推荐
{ConstitutionType: "pinghe", ProductID: 18, Priority: 1, Reason: "日常滋补"},
{ConstitutionType: "pinghe", ProductID: 20, Priority: 2, Reason: "营养补充"},
}
}
func getSymptomProductSeeds() []model.SymptomProduct {
return []model.SymptomProduct{
// 疲劳相关
{Keyword: "疲劳", ProductID: 1, Priority: 1},
{Keyword: "乏力", ProductID: 1, Priority: 1},
{Keyword: "没精神", ProductID: 2, Priority: 1},
{Keyword: "体力差", ProductID: 2, Priority: 1},
// 睡眠相关
{Keyword: "失眠", ProductID: 29, Priority: 1},
{Keyword: "睡不着", ProductID: 29, Priority: 1},
{Keyword: "多梦", ProductID: 30, Priority: 1},
{Keyword: "睡眠差", ProductID: 30, Priority: 1},
// 心脑血管相关
{Keyword: "血压高", ProductID: 21, Priority: 1},
{Keyword: "高血压", ProductID: 21, Priority: 1},
{Keyword: "血脂高", ProductID: 21, Priority: 1},
{Keyword: "高血脂", ProductID: 23, Priority: 1},
{Keyword: "头晕", ProductID: 22, Priority: 1},
{Keyword: "动脉硬化", ProductID: 22, Priority: 1},
// 骨关节相关
{Keyword: "关节痛", ProductID: 24, Priority: 1},
{Keyword: "膝盖疼", ProductID: 24, Priority: 1},
{Keyword: "腿疼", ProductID: 24, Priority: 1},
{Keyword: "骨质疏松", ProductID: 25, Priority: 1},
{Keyword: "缺钙", ProductID: 25, Priority: 1},
// 血糖相关
{Keyword: "血糖高", ProductID: 27, Priority: 1},
{Keyword: "糖尿病", ProductID: 28, Priority: 1},
// 记忆力相关
{Keyword: "记忆力差", ProductID: 31, Priority: 1},
{Keyword: "健忘", ProductID: 31, Priority: 1},
{Keyword: "脑供血不足", ProductID: 32, Priority: 1},
// 消化相关
{Keyword: "便秘", ProductID: 33, Priority: 1},
{Keyword: "排便困难", ProductID: 33, Priority: 1},
{Keyword: "消化不良", ProductID: 34, Priority: 1},
// 眼睛相关
{Keyword: "眼睛干", ProductID: 35, Priority: 1},
{Keyword: "视力差", ProductID: 35, Priority: 1},
{Keyword: "眼疲劳", ProductID: 35, Priority: 1},
// 免疫相关
{Keyword: "感冒", ProductID: 36, Priority: 1},
{Keyword: "免疫力差", ProductID: 36, Priority: 1},
{Keyword: "体质差", ProductID: 36, Priority: 1},
// 体质相关症状
{Keyword: "怕冷", ProductID: 4, Priority: 1},
{Keyword: "手脚冰凉", ProductID: 5, Priority: 1},
{Keyword: "口干", ProductID: 6, Priority: 1},
{Keyword: "上火", ProductID: 11, Priority: 1},
{Keyword: "湿气重", ProductID: 9, Priority: 1},
}
}
```
### 步骤 3:创建产品 Repository
创建 `server/internal/repository/impl/product.go`
```go
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type ProductRepository struct{}
func NewProductRepository() *ProductRepository {
return &ProductRepository{}
}
// GetAll 获取所有产品
func (r *ProductRepository) GetAll(category string) ([]model.Product, error) {
var products []model.Product
query := database.DB.Where("is_active = ?", true)
if category != "" {
query = query.Where("category = ?", category)
}
err := query.Find(&products).Error
return products, err
}
// GetByID 获取产品详情
func (r *ProductRepository) GetByID(id uint) (*model.Product, error) {
var product model.Product
err := database.DB.First(&product, id).Error
return &product, err
}
// GetByConstitution 根据体质获取推荐产品
func (r *ProductRepository) GetByConstitution(constitutionType string, limit int) ([]model.Product, error) {
var products []model.Product
err := database.DB.
Joins("JOIN constitution_products ON constitution_products.product_id = products.id").
Where("constitution_products.constitution_type = ?", constitutionType).
Where("products.is_active = ?", true).
Order("constitution_products.priority ASC").
Limit(limit).
Find(&products).Error
return products, err
}
// GetByKeywords 根据症状关键词获取推荐产品
func (r *ProductRepository) GetByKeywords(keywords []string, limit int) ([]model.Product, error) {
var products []model.Product
err := database.DB.
Joins("JOIN symptom_products ON symptom_products.product_id = products.id").
Where("symptom_products.keyword IN ?", keywords).
Where("products.is_active = ?", true).
Order("symptom_products.priority ASC").
Distinct().
Limit(limit).
Find(&products).Error
return products, err
}
// GetRecommendations 综合推荐(体质+关键词)
func (r *ProductRepository) GetRecommendations(constitutionType string, keywords []string, limit int) ([]model.Product, error) {
// 优先获取体质相关产品
products, _ := r.GetByConstitution(constitutionType, limit)
// 如果不够,补充关键词匹配的产品
if len(products) < limit && len(keywords) > 0 {
remaining := limit - len(products)
keywordProducts, _ := r.GetByKeywords(keywords, remaining)
// 去重合并
existingIDs := make(map[uint]bool)
for _, p := range products {
existingIDs[p.ID] = true
}
for _, p := range keywordProducts {
if !existingIDs[p.ID] {
products = append(products, p)
}
}
}
return products, nil
}
```
### 步骤 4:更新对话 Service
`conversation.go` 中添加产品推荐:
```go
// 更新系统提示词模板,添加产品列表
func (s *ConversationService) buildSystemPrompt(userID uint) string {
// ... 原有代码 ...
// 获取用户体质相关产品(用于 AI 推荐)
var productList string
if constitution != nil {
products, _ := s.productRepo.GetByConstitution(constitution.PrimaryConstitution, 5)
if len(products) > 0 {
productList = "可推荐产品:\n"
for _, p := range products {
productList += fmt.Sprintf("- %s ¥%.0f %s\n", p.Name, p.Price, p.MallURL)
}
}
}
return fmt.Sprintf(systemPromptTemplate, userProfile, constitutionInfo, medicationHistory, productList)
}
```
### 步骤 5:创建产品 Handler
创建 `server/internal/api/handler/product.go`
```go
package handler
import (
"health-ai/internal/api/middleware"
"health-ai/internal/repository/impl"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type ProductHandler struct {
productRepo *impl.ProductRepository
constitutionRepo *impl.ConstitutionRepository
}
func NewProductHandler() *ProductHandler {
return &ProductHandler{
productRepo: impl.NewProductRepository(),
constitutionRepo: impl.NewConstitutionRepository(),
}
}
// GetProducts 获取产品列表
func (h *ProductHandler) GetProducts(c *gin.Context) {
category := c.Query("category")
products, err := h.productRepo.GetAll(category)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, products)
}
// GetProduct 获取产品详情
func (h *ProductHandler) GetProduct(c *gin.Context) {
var id uint
fmt.Sscanf(c.Param("id"), "%d", &id)
product, err := h.productRepo.GetByID(id)
if err != nil {
response.Error(c, 404, "产品不存在")
return
}
response.Success(c, product)
}
// GetRecommendProducts 获取推荐产品
func (h *ProductHandler) GetRecommendProducts(c *gin.Context) {
userID := middleware.GetUserID(c)
// 获取用户体质
constitution, err := h.constitutionRepo.GetLatestAssessment(userID)
if err != nil {
response.Error(c, 400, "请先完成体质测评")
return
}
products, err := h.productRepo.GetByConstitution(constitution.PrimaryConstitution, 6)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, products)
}
// SearchProducts 搜索产品
func (h *ProductHandler) SearchProducts(c *gin.Context) {
keyword := c.Query("keyword")
if keyword == "" {
response.Error(c, 400, "请输入搜索关键词")
return
}
products, err := h.productRepo.GetByKeywords([]string{keyword}, 10)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, products)
}
```
### 步骤 6:更新路由
`router.go` 中添加:
```go
// 产品路由
products := api.Group("/products")
{
products.GET("", productHandler.GetProducts)
products.GET("/:id", productHandler.GetProduct)
products.GET("/recommend", authMiddleware, productHandler.GetRecommendProducts)
products.GET("/search", productHandler.SearchProducts)
}
```
### 步骤 7:更新数据库初始化
`main.go` 中调用:
```go
// 初始化产品数据
database.SeedProducts()
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `internal/model/product.go` | 产品数据模型 |
| `internal/database/seed_products.go` | 产品种子数据(36条) |
| `internal/repository/impl/product.go` | 产品 Repository |
| `internal/api/handler/product.go` | 产品 Handler |
---
## 模拟数据统计
| 分类 | 数量 | 说明 |
|------|------|------|
| 体质调养类 | 20 | 补气、温阳、滋阴、祛湿、活血、理气、抗敏、综合 |
| 中老年常见类 | 16 | 心脑血管、骨关节、血糖、助眠、健脑、润肠、护眼、免疫 |
| **总计** | **36** | - |
---
## 验收标准
- [ ] 产品列表正常显示
- [ ] 按分类筛选正常
- [ ] 根据体质推荐产品正常
- [ ] 根据症状搜索产品正常
- [ ] AI 回答中包含产品推荐链接
- [ ] 种子数据正确初始化
---
## 预计耗时
30-40 分钟
---
## 下一步
后端开发全部完成!进入 `03-Web前端开发/01-项目结构初始化.md`

429
TODOS/03-Web前端开发/01-项目结构初始化.md

@ -0,0 +1,429 @@
# 01-Web 前端项目结构初始化
## 目标
使用 Vite 创建 Vue 3 + TypeScript 项目,配置基础依赖和目录结构。
---
## 前置要求
- Node.js 18+ 已安装
- npm/pnpm 已配置
---
## 实施步骤
### 步骤 1:创建 Vue 3 项目
```bash
cd I:\apps\demo\healthApps
# 使用 Vite 创建项目
npm create vite@latest web -- --template vue-ts
cd web
```
### 步骤 2:安装依赖
```bash
# 基础依赖
npm install
# 路由
npm install vue-router@4
# 状态管理
npm install pinia
# UI 组件库
npm install element-plus
npm install @element-plus/icons-vue
# HTTP 请求
npm install axios
# 图表(体质雷达图)
npm install echarts vue-echarts
# 工具库
npm install dayjs
npm install lodash-es
npm install @types/lodash-es -D
```
### 步骤 3:创建目录结构
```bash
cd src
# 创建目录
mkdir -p api
mkdir -p components/common
mkdir -p components/survey
mkdir -p components/constitution
mkdir -p components/chat
mkdir -p views/auth
mkdir -p views/survey
mkdir -p views/constitution
mkdir -p views/chat
mkdir -p views/profile
mkdir -p stores
mkdir -p router
mkdir -p utils
mkdir -p types
mkdir -p assets/styles
```
### 步骤 4:配置 Element Plus
更新 `src/main.ts`
```typescript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './assets/styles/global.css'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
```
### 步骤 5:创建全局样式
创建 `src/assets/styles/global.css`
```css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: #ddd;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #ccc;
}
/* 通用类 */
.page-container {
min-height: 100%;
padding: 20px;
background-color: #f5f7fa;
}
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
```
### 步骤 6:创建类型定义
创建 `src/types/index.ts`
```typescript
// 用户相关
export interface User {
id: number
phone: string
email: string
nickname: string
avatar: string
survey_completed: boolean
}
// 健康档案
export interface HealthProfile {
id: number
name: string
birth_date: string
gender: string
height: number
weight: number
bmi: number
blood_type: string
occupation: string
marital_status: string
region: string
}
// 生活习惯
export interface LifestyleInfo {
sleep_time: string
wake_time: string
sleep_quality: string
meal_regularity: string
diet_preference: string
daily_water_ml: number
exercise_frequency: string
exercise_type: string
exercise_duration_min: number
is_smoker: boolean
alcohol_frequency: string
}
// 体质问题
export interface Question {
id: number
constitution_type: string
question_text: string
options: string[]
order_num: number
}
// 体质得分
export interface ConstitutionScore {
type: string
name: string
score: number
description: string
}
// 体质结果
export interface ConstitutionResult {
primary_constitution: ConstitutionScore
secondary_constitutions: ConstitutionScore[]
all_scores: ConstitutionScore[]
recommendations: Record<string, Record<string, string>>
assessed_at: string
}
// 对话
export interface Conversation {
id: number
title: string
created_at: string
updated_at: string
}
// 消息
export interface Message {
id: number
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
// API 响应
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
```
### 步骤 7:创建 API 基础配置
创建 `src/api/request.ts`
```typescript
import axios from 'axios'
import type { ApiResponse } from '@/types'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
timeout: 30000,
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const res = response.data as ApiResponse
if (res.code !== 0) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res.data
},
(error) => {
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
window.location.href = '/login'
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request
```
### 步骤 8:创建环境变量文件
创建 `web/.env.development`
```
VITE_API_BASE_URL=http://localhost:8080/api
```
创建 `web/.env.production`
```
VITE_API_BASE_URL=/api
```
### 步骤 9:配置 Vite
更新 `web/vite.config.ts`
```typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
```
### 步骤 10:验证项目
```bash
npm run dev
# 访问 http://localhost:3000
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/main.ts` | 应用入口(更新) |
| `src/assets/styles/global.css` | 全局样式 |
| `src/types/index.ts` | 类型定义 |
| `src/api/request.ts` | 请求封装 |
| `.env.development` | 开发环境变量 |
| `.env.production` | 生产环境变量 |
| `vite.config.ts` | Vite 配置(更新) |
---
## 最终目录结构
```
web/
├── src/
│ ├── api/
│ │ └── request.ts
│ ├── assets/
│ │ └── styles/
│ │ └── global.css
│ ├── components/
│ │ ├── common/
│ │ ├── survey/
│ │ ├── constitution/
│ │ └── chat/
│ ├── views/
│ │ ├── auth/
│ │ ├── survey/
│ │ ├── constitution/
│ │ ├── chat/
│ │ └── profile/
│ ├── stores/
│ ├── router/
│ ├── utils/
│ ├── types/
│ │ └── index.ts
│ ├── App.vue
│ └── main.ts
├── .env.development
├── .env.production
├── vite.config.ts
└── package.json
```
---
## 验收标准
- [ ] 项目创建成功
- [ ] 所有依赖安装完成
- [ ] 目录结构创建完整
- [ ] `npm run dev` 正常启动
- [ ] 访问 http://localhost:3000 看到页面
---
## 预计耗时
15-20 分钟
---
## 下一步
完成后进入 `03-Web前端开发/02-路由和布局设计.md`

594
TODOS/03-Web前端开发/02-路由和布局设计.md

@ -0,0 +1,594 @@
# 02-路由和布局设计
## 目标
配置 Vue Router 路由系统,实现页面布局和导航守卫。
---
## 前置要求
- 项目结构已初始化
- Vue Router 已安装
---
## 实施步骤
### 步骤 1:创建用户 Store
创建 `src/stores/user.ts`
```typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref<any>(null)
const isLoggedIn = computed(() => !!token.value)
const surveyCompleted = computed(() => userInfo.value?.survey_completed || false)
function setToken(newToken: string) {
token.value = newToken
localStorage.setItem('token', newToken)
}
function setUserInfo(info: any) {
userInfo.value = info
}
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
return {
token,
userInfo,
isLoggedIn,
surveyCompleted,
setToken,
setUserInfo,
logout,
}
})
```
### 步骤 2:创建路由配置
创建 `src/router/index.ts`
```typescript
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/user'
// 布局组件
import MainLayout from '@/components/common/MainLayout.vue'
import AuthLayout from '@/components/common/AuthLayout.vue'
const routes: RouteRecordRaw[] = [
// 认证相关页面
{
path: '/auth',
component: AuthLayout,
children: [
{
path: 'login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { title: '登录' }
},
{
path: 'register',
name: 'Register',
component: () => import('@/views/auth/Register.vue'),
meta: { title: '注册' }
},
],
},
// 健康调查(新用户必经)
{
path: '/survey',
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Survey',
component: () => import('@/views/survey/Index.vue'),
meta: { title: '健康调查' }
},
],
},
// 体质测评
{
path: '/constitution',
component: MainLayout,
meta: { requiresAuth: true, requiresSurvey: true },
children: [
{
path: '',
name: 'Constitution',
component: () => import('@/views/constitution/Index.vue'),
meta: { title: '体质测评' }
},
{
path: 'result',
name: 'ConstitutionResult',
component: () => import('@/views/constitution/Result.vue'),
meta: { title: '体质结果' }
},
],
},
// 主要功能页面
{
path: '/',
component: MainLayout,
meta: { requiresAuth: true, requiresSurvey: true },
children: [
{
path: '',
redirect: '/chat'
},
{
path: 'chat',
name: 'Chat',
component: () => import('@/views/chat/Index.vue'),
meta: { title: 'AI问诊' }
},
{
path: 'chat/:id',
name: 'ChatDetail',
component: () => import('@/views/chat/Detail.vue'),
meta: { title: '对话详情' }
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/profile/Index.vue'),
meta: { title: '个人中心' }
},
{
path: 'health-record',
name: 'HealthRecord',
component: () => import('@/views/profile/HealthRecord.vue'),
meta: { title: '健康档案' }
},
],
},
// 404
{
path: '/:pathMatch(.*)*',
redirect: '/'
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
// 设置页面标题
document.title = (to.meta.title as string) + ' - 健康AI助手' || '健康AI助手'
// 检查是否需要登录
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
next({ path: '/auth/login', query: { redirect: to.fullPath } })
return
}
// 检查是否需要完成调查
if (to.meta.requiresSurvey && !userStore.surveyCompleted) {
// 如果已登录但未完成调查,跳转到调查页
if (to.path !== '/survey') {
next('/survey')
return
}
}
// 已登录用户访问登录页,跳转到首页
if ((to.path === '/auth/login' || to.path === '/auth/register') && userStore.isLoggedIn) {
next('/')
return
}
next()
})
export default router
```
### 步骤 3:创建认证布局组件
创建 `src/components/common/AuthLayout.vue`
```vue
<template>
<div class="auth-layout">
<div class="auth-container">
<div class="auth-header">
<img src="@/assets/logo.svg" alt="Logo" class="logo" />
<h1>健康AI助手</h1>
<p>您的智能健康管家</p>
</div>
<div class="auth-content">
<router-view />
</div>
</div>
</div>
</template>
<style scoped>
.auth-layout {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.auth-container {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.auth-header {
text-align: center;
margin-bottom: 30px;
}
.logo {
width: 64px;
height: 64px;
margin-bottom: 16px;
}
.auth-header h1 {
font-size: 24px;
color: #333;
margin-bottom: 8px;
}
.auth-header p {
font-size: 14px;
color: #999;
}
</style>
```
### 步骤 4:创建主布局组件
创建 `src/components/common/MainLayout.vue`
```vue
<template>
<el-container class="main-layout">
<!-- 侧边栏 -->
<el-aside :width="isCollapsed ? '64px' : '220px'" class="sidebar">
<div class="logo-container">
<img src="@/assets/logo.svg" alt="Logo" class="logo" />
<span v-if="!isCollapsed" class="logo-text">健康AI助手</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapsed"
router
class="sidebar-menu"
>
<el-menu-item index="/chat">
<el-icon><ChatDotRound /></el-icon>
<span>AI问诊</span>
</el-menu-item>
<el-menu-item index="/constitution">
<el-icon><User /></el-icon>
<span>体质测评</span>
</el-menu-item>
<el-menu-item index="/health-record">
<el-icon><Document /></el-icon>
<span>健康档案</span>
</el-menu-item>
<el-menu-item index="/profile">
<el-icon><Setting /></el-icon>
<span>个人中心</span>
</el-menu-item>
</el-menu>
<div class="sidebar-footer">
<el-button
:icon="isCollapsed ? 'Expand' : 'Fold'"
text
@click="toggleCollapse"
/>
</div>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶部导航 -->
<el-header class="header">
<div class="header-left">
<h2>{{ currentTitle }}</h2>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" :src="userStore.userInfo?.avatar">
{{ userStore.userInfo?.nickname?.charAt(0) || 'U' }}
</el-avatar>
<span class="username">{{ userStore.userInfo?.nickname || '用户' }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 页面内容 -->
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessageBox } from 'element-plus'
import {
ChatDotRound,
User,
Document,
Setting,
ArrowDown,
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapsed = ref(false)
const activeMenu = computed(() => route.path)
const currentTitle = computed(() => route.meta.title as string || '健康AI助手')
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
const handleCommand = (command: string) => {
if (command === 'logout') {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
userStore.logout()
router.push('/auth/login')
})
} else if (command === 'profile') {
router.push('/profile')
}
}
</script>
<style scoped>
.main-layout {
height: 100vh;
}
.sidebar {
background: #304156;
display: flex;
flex-direction: column;
transition: width 0.3s;
}
.logo-container {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.logo {
width: 32px;
height: 32px;
}
.logo-text {
margin-left: 12px;
font-size: 16px;
font-weight: bold;
color: #fff;
white-space: nowrap;
}
.sidebar-menu {
flex: 1;
border-right: none;
background: transparent;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 100%;
}
:deep(.el-menu-item) {
color: rgba(255, 255, 255, 0.7);
}
:deep(.el-menu-item:hover),
:deep(.el-menu-item.is-active) {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
.sidebar-footer {
padding: 16px;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.header-left h2 {
font-size: 18px;
font-weight: 500;
color: #333;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
}
.username {
margin: 0 8px;
font-size: 14px;
color: #666;
}
.main-content {
background: #f5f7fa;
padding: 20px;
overflow-y: auto;
}
</style>
```
### 步骤 5:创建 Logo 占位
创建 `src/assets/logo.svg`
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="30" fill="#667eea"/>
<path d="M32 16c-8.8 0-16 7.2-16 16s7.2 16 16 16 16-7.2 16-16-7.2-16-16-16zm0 28c-6.6 0-12-5.4-12-12s5.4-12 12-12 12 5.4 12 12-5.4 12-12 12z" fill="#fff"/>
<path d="M32 24v16M24 32h16" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
</svg>
```
### 步骤 6:更新 App.vue
更新 `src/App.vue`
```vue
<template>
<router-view />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { getUserProfile } from '@/api/user'
const userStore = useUserStore()
onMounted(async () => {
// 如果已登录,获取用户信息
if (userStore.isLoggedIn) {
try {
const userInfo = await getUserProfile()
userStore.setUserInfo(userInfo)
} catch (error) {
// Token 可能已失效
userStore.logout()
}
}
})
</script>
```
### 步骤 7:创建用户 API
创建 `src/api/user.ts`
```typescript
import request from './request'
export const getUserProfile = () => {
return request.get('/user/profile')
}
export const updateUserProfile = (data: any) => {
return request.put('/user/profile', data)
}
export const getHealthProfile = () => {
return request.get('/user/health-profile')
}
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/stores/user.ts` | 用户状态管理 |
| `src/router/index.ts` | 路由配置 |
| `src/components/common/AuthLayout.vue` | 认证布局 |
| `src/components/common/MainLayout.vue` | 主布局 |
| `src/assets/logo.svg` | Logo 图标 |
| `src/App.vue` | 根组件(更新) |
| `src/api/user.ts` | 用户 API |
---
## 路由结构说明
| 路径 | 组件 | 说明 |
|------|------|------|
| /auth/login | Login.vue | 登录页 |
| /auth/register | Register.vue | 注册页 |
| /survey | Survey/Index.vue | 健康调查 |
| /constitution | Constitution/Index.vue | 体质测评 |
| /constitution/result | Constitution/Result.vue | 体质结果 |
| /chat | Chat/Index.vue | AI问诊列表 |
| /chat/:id | Chat/Detail.vue | 对话详情 |
| /profile | Profile/Index.vue | 个人中心 |
| /health-record | Profile/HealthRecord.vue | 健康档案 |
---
## 验收标准
- [ ] 路由配置正确加载
- [ ] 未登录自动跳转登录页
- [ ] 登录布局显示正常
- [ ] 主布局侧边栏显示正常
- [ ] 路由守卫逻辑正确
---
## 预计耗时
25-30 分钟
---
## 下一步
完成后进入 `03-Web前端开发/03-用户认证页面.md`

478
TODOS/03-Web前端开发/03-用户认证页面.md

@ -0,0 +1,478 @@
# 03-用户认证页面
## 目标
实现登录和注册页面,完成用户认证功能。
---
## UI 设计参考
> 参考设计稿:`files/ui/登录页.png`
### 页面布局
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色渐变背景 (`#10B981 → #2EC4B6`) + 医疗插图 |
| Logo | "AI健康助手" 标题 + "您的专属健康管理伙伴" |
| 表单 | 白色圆角卡片 (16px),内边距 24px |
| 输入框 | 灰色背景 `#F3F4F6`,圆角 24px |
| 主按钮 | 绿色 `#10B981`,全宽,圆角 24px |
| 底部 | 注册/登录切换链接 |
### 配色规范
```scss
$primary: #10B981; // 主色调
$primary-dark: #059669; // 深色(hover)
$bg-page: #F5F5F5; // 页面背景
$bg-input: #F3F4F6; // 输入框背景
$text-main: #1F2937; // 主文字
$text-sub: #6B7280; // 次文字
$text-hint: #9CA3AF; // 提示文字
```
---
## 前置要求
- 路由和布局已配置
- 后端认证接口可用
---
## 实施步骤
### 步骤 1:创建认证 API
创建 `src/api/auth.ts`
```typescript
import request from './request'
export interface LoginRequest {
phone: string
password: string
}
export interface RegisterRequest {
phone: string
password: string
nickname?: string
}
export interface AuthResponse {
token: string
user_id: number
nickname: string
survey_completed: boolean
}
export const login = (data: LoginRequest): Promise<AuthResponse> => {
return request.post('/auth/login', data)
}
export const register = (data: RegisterRequest): Promise<AuthResponse> => {
return request.post('/auth/register', data)
}
```
### 步骤 2:创建登录页面
创建 `src/views/auth/Login.vue`
```vue
<template>
<div class="login-page">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent="handleLogin"
>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="form.phone"
placeholder="请输入手机号"
prefix-icon="Phone"
size="large"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
style="width: 100%"
native-type="submit"
>
登录
</el-button>
</el-form-item>
<div class="form-footer">
<span>还没有账号?</span>
<router-link to="/auth/register">立即注册</router-link>
</div>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { login } from '@/api/auth'
import { getUserProfile } from '@/api/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
phone: '',
password: '',
})
const rules: FormRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
}
const handleLogin = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
loading.value = true
try {
const res = await login(form)
// 保存 token 和用户信息
userStore.setToken(res.token)
userStore.setUserInfo({
id: res.user_id,
nickname: res.nickname,
survey_completed: res.survey_completed,
})
// 获取完整用户信息
const userInfo = await getUserProfile()
userStore.setUserInfo(userInfo)
ElMessage.success('登录成功')
// 跳转到目标页面或首页
const redirect = route.query.redirect as string
if (res.survey_completed) {
router.push(redirect || '/')
} else {
router.push('/survey')
}
} catch (error) {
// 错误已在拦截器中处理
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
padding: 20px 0;
}
.form-footer {
text-align: center;
font-size: 14px;
color: #666;
}
.form-footer a {
color: #409eff;
text-decoration: none;
margin-left: 8px;
}
.form-footer a:hover {
text-decoration: underline;
}
</style>
```
### 步骤 3:创建注册页面
创建 `src/views/auth/Register.vue`
```vue
<template>
<div class="register-page">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent="handleRegister"
>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="form.phone"
placeholder="请输入手机号"
prefix-icon="Phone"
size="large"
/>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input
v-model="form.nickname"
placeholder="请输入昵称(选填)"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码(至少6位)"
prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
type="password"
placeholder="请再次输入密码"
prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.agreement">
我已阅读并同意
<a href="javascript:;" @click.stop="showAgreement">《用户协议》</a>
<a href="javascript:;" @click.stop="showPrivacy">《隐私政策》</a>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="!form.agreement"
style="width: 100%"
native-type="submit"
>
注册
</el-button>
</el-form-item>
<div class="form-footer">
<span>已有账号?</span>
<router-link to="/auth/login">立即登录</router-link>
</div>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { register } from '@/api/auth'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
phone: '',
nickname: '',
password: '',
confirmPassword: '',
agreement: false,
})
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
if (value !== form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const rules: FormRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
}
const handleRegister = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
if (!form.agreement) {
ElMessage.warning('请先同意用户协议和隐私政策')
return
}
loading.value = true
try {
const res = await register({
phone: form.phone,
password: form.password,
nickname: form.nickname || undefined,
})
// 保存 token
userStore.setToken(res.token)
userStore.setUserInfo({
id: res.user_id,
nickname: res.nickname,
survey_completed: false,
})
ElMessage.success('注册成功,请完成健康调查')
router.push('/survey')
} catch (error) {
// 错误已在拦截器中处理
} finally {
loading.value = false
}
}
const showAgreement = () => {
ElMessageBox.alert(
'本应用仅提供健康咨询建议,不构成医疗诊断。如有不适,请及时就医。',
'用户协议',
{ confirmButtonText: '我知道了' }
)
}
const showPrivacy = () => {
ElMessageBox.alert(
'我们重视您的隐私,您的健康信息将被加密存储,不会泄露给第三方。',
'隐私政策',
{ confirmButtonText: '我知道了' }
)
}
</script>
<style scoped>
.register-page {
padding: 20px 0;
}
.el-checkbox {
height: auto;
line-height: 1.5;
}
.el-checkbox a {
color: #409eff;
}
.form-footer {
text-align: center;
font-size: 14px;
color: #666;
}
.form-footer a {
color: #409eff;
text-decoration: none;
margin-left: 8px;
}
</style>
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/api/auth.ts` | 认证 API |
| `src/views/auth/Login.vue` | 登录页面 |
| `src/views/auth/Register.vue` | 注册页面 |
---
## 功能说明
### 登录页面
- 手机号+密码登录
- 表单验证
- 登录成功后:
- 已完成调查 → 跳转首页
- 未完成调查 → 跳转健康调查页
### 注册页面
- 手机号+密码注册
- 可选昵称
- 密码确认
- 用户协议确认
- 注册成功后跳转健康调查页
---
## 验收标准
- [ ] 登录表单验证正常
- [ ] 注册表单验证正常
- [ ] 登录成功保存 Token
- [ ] 注册成功自动登录
- [ ] 路由跳转逻辑正确
---
## 预计耗时
20-25 分钟
---
## 下一步
完成后进入 `03-Web前端开发/04-健康调查页面.md`

845
TODOS/03-Web前端开发/04-健康调查页面.md

@ -0,0 +1,845 @@
# 04-健康调查页面
## 目标
实现新用户健康调查功能,包括基础信息、生活习惯、病史等多步骤表单。
---
## 前置要求
- 用户认证功能完成
- 后端调查接口可用
---
## 实施步骤
### 步骤 1:创建调查 API
创建 `src/api/survey.ts`
```typescript
import request from './request'
export const getSurveyStatus = () => {
return request.get('/survey/status')
}
export const submitBasicInfo = (data: any) => {
return request.post('/survey/basic-info', data)
}
export const submitLifestyle = (data: any) => {
return request.post('/survey/lifestyle', data)
}
export const submitMedicalHistory = (data: any) => {
return request.post('/survey/medical-history', data)
}
export const submitFamilyHistory = (data: any) => {
return request.post('/survey/family-history', data)
}
export const submitAllergy = (data: any) => {
return request.post('/survey/allergy', data)
}
```
### 步骤 2:创建调查主页面
创建 `src/views/survey/Index.vue`
```vue
<template>
<div class="survey-page">
<div class="survey-container">
<!-- 步骤指示器 -->
<el-steps :active="currentStep" finish-status="success" align-center>
<el-step title="基础信息" />
<el-step title="生活习惯" />
<el-step title="健康状况" />
<el-step title="完成" />
</el-steps>
<!-- 表单内容 -->
<div class="form-container">
<!-- 步骤 1: 基础信息 -->
<BasicInfoForm
v-if="currentStep === 0"
@next="handleBasicInfoNext"
/>
<!-- 步骤 2: 生活习惯 -->
<LifestyleForm
v-else-if="currentStep === 1"
@prev="currentStep--"
@next="handleLifestyleNext"
/>
<!-- 步骤 3: 健康状况 -->
<HealthStatusForm
v-else-if="currentStep === 2"
@prev="currentStep--"
@next="handleHealthStatusNext"
/>
<!-- 步骤 4: 完成 -->
<div v-else class="complete-step">
<el-result
icon="success"
title="健康调查完成"
sub-title="您已完成基础健康信息录入,接下来进行体质测评"
>
<template #extra>
<el-button type="primary" size="large" @click="goToConstitution">
开始体质测评
</el-button>
</template>
</el-result>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import BasicInfoForm from '@/components/survey/BasicInfoForm.vue'
import LifestyleForm from '@/components/survey/LifestyleForm.vue'
import HealthStatusForm from '@/components/survey/HealthStatusForm.vue'
const router = useRouter()
const userStore = useUserStore()
const currentStep = ref(0)
const handleBasicInfoNext = () => {
currentStep.value = 1
}
const handleLifestyleNext = () => {
currentStep.value = 2
}
const handleHealthStatusNext = () => {
currentStep.value = 3
// 更新用户状态
if (userStore.userInfo) {
userStore.userInfo.survey_completed = true
}
}
const goToConstitution = () => {
router.push('/constitution')
}
</script>
<style scoped>
.survey-page {
min-height: 100%;
padding: 20px;
}
.survey-container {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.form-container {
margin-top: 40px;
}
.complete-step {
padding: 40px 0;
}
</style>
```
### 步骤 3:创建基础信息表单组件
创建 `src/components/survey/BasicInfoForm.vue`
```vue
<template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
@submit.prevent="handleSubmit"
>
<h3 class="form-title">基础信息</h3>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="form.gender">
<el-radio label="male"></el-radio>
<el-radio label="female"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="出生日期" prop="birth_date">
<el-date-picker
v-model="form.birth_date"
type="date"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="血型" prop="blood_type">
<el-select v-model="form.blood_type" placeholder="请选择" style="width: 100%">
<el-option label="A型" value="A" />
<el-option label="B型" value="B" />
<el-option label="AB型" value="AB" />
<el-option label="O型" value="O" />
<el-option label="不清楚" value="" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="身高(cm)" prop="height">
<el-input-number
v-model="form.height"
:min="100"
:max="250"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="体重(kg)" prop="weight">
<el-input-number
v-model="form.weight"
:min="30"
:max="200"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="职业" prop="occupation">
<el-input v-model="form.occupation" placeholder="请输入职业" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="婚姻状况" prop="marital_status">
<el-select v-model="form.marital_status" placeholder="请选择" style="width: 100%">
<el-option label="未婚" value="single" />
<el-option label="已婚" value="married" />
<el-option label="离异" value="divorced" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="所在地区" prop="region">
<el-input v-model="form.region" placeholder="请输入所在城市" />
</el-form-item>
<div class="form-actions">
<el-button type="primary" size="large" :loading="loading" native-type="submit">
下一步
</el-button>
</div>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { submitBasicInfo } from '@/api/survey'
const emit = defineEmits(['next'])
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
name: '',
gender: '',
birth_date: null,
blood_type: '',
height: 170,
weight: 60,
occupation: '',
marital_status: '',
region: '',
})
const rules: FormRules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
height: [{ required: true, message: '请输入身高', trigger: 'change' }],
weight: [{ required: true, message: '请输入体重', trigger: 'change' }],
}
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
loading.value = true
try {
await submitBasicInfo(form)
ElMessage.success('基础信息保存成功')
emit('next')
} catch (error) {
// 错误已处理
} finally {
loading.value = false
}
}
</script>
<style scoped>
.form-title {
font-size: 18px;
font-weight: 500;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
}
.form-actions {
margin-top: 24px;
text-align: right;
}
</style>
```
### 步骤 4:创建生活习惯表单组件
创建 `src/components/survey/LifestyleForm.vue`
```vue
<template>
<el-form
ref="formRef"
:model="form"
label-width="120px"
@submit.prevent="handleSubmit"
>
<h3 class="form-title">生活习惯</h3>
<h4 class="section-title">作息习惯</h4>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="入睡时间">
<el-time-select
v-model="form.sleep_time"
start="20:00"
step="00:30"
end="02:00"
placeholder="选择时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="起床时间">
<el-time-select
v-model="form.wake_time"
start="05:00"
step="00:30"
end="12:00"
placeholder="选择时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="睡眠质量">
<el-radio-group v-model="form.sleep_quality">
<el-radio label="good"></el-radio>
<el-radio label="normal">一般</el-radio>
<el-radio label="poor"></el-radio>
</el-radio-group>
</el-form-item>
<h4 class="section-title">饮食习惯</h4>
<el-form-item label="三餐规律">
<el-radio-group v-model="form.meal_regularity">
<el-radio label="regular">规律</el-radio>
<el-radio label="irregular">不规律</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="饮食偏好">
<el-input v-model="form.diet_preference" placeholder="如:偏辣、偏甜、素食等" />
</el-form-item>
<el-form-item label="每日饮水量">
<el-slider
v-model="form.daily_water_ml"
:min="500"
:max="3000"
:step="100"
:format-tooltip="(val) => val + 'ml'"
show-input
/>
</el-form-item>
<h4 class="section-title">运动习惯</h4>
<el-form-item label="运动频率">
<el-radio-group v-model="form.exercise_frequency">
<el-radio label="never">从不</el-radio>
<el-radio label="sometimes">偶尔</el-radio>
<el-radio label="often">经常</el-radio>
<el-radio label="daily">每天</el-radio>
</el-radio-group>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="运动类型">
<el-input v-model="form.exercise_type" placeholder="如:跑步、游泳" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="每次时长(分钟)">
<el-input-number v-model="form.exercise_duration_min" :min="0" :max="240" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<h4 class="section-title">烟酒情况</h4>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="是否吸烟">
<el-switch v-model="form.is_smoker" active-text="是" inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="饮酒频率">
<el-select v-model="form.alcohol_frequency" style="width: 100%">
<el-option label="从不" value="never" />
<el-option label="偶尔" value="sometimes" />
<el-option label="经常" value="often" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="form-actions">
<el-button size="large" @click="$emit('prev')">上一步</el-button>
<el-button type="primary" size="large" :loading="loading" native-type="submit">
下一步
</el-button>
</div>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { submitLifestyle } from '@/api/survey'
const emit = defineEmits(['prev', 'next'])
const loading = ref(false)
const form = reactive({
sleep_time: '23:00',
wake_time: '07:00',
sleep_quality: 'normal',
meal_regularity: 'regular',
diet_preference: '',
daily_water_ml: 1500,
exercise_frequency: 'sometimes',
exercise_type: '',
exercise_duration_min: 30,
is_smoker: false,
alcohol_frequency: 'never',
})
const handleSubmit = async () => {
loading.value = true
try {
await submitLifestyle(form)
ElMessage.success('生活习惯保存成功')
emit('next')
} catch (error) {
// 错误已处理
} finally {
loading.value = false
}
}
</script>
<style scoped>
.form-title {
font-size: 18px;
font-weight: 500;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
}
.section-title {
font-size: 14px;
color: #666;
margin: 20px 0 12px;
}
.form-actions {
margin-top: 24px;
text-align: right;
}
.form-actions .el-button + .el-button {
margin-left: 12px;
}
</style>
```
### 步骤 5:创建健康状况表单组件
创建 `src/components/survey/HealthStatusForm.vue`
```vue
<template>
<div class="health-status-form">
<h3 class="form-title">健康状况</h3>
<!-- 既往病史 -->
<div class="section">
<div class="section-header">
<h4>既往病史</h4>
<el-button type="primary" text @click="addMedicalHistory">
<el-icon><Plus /></el-icon> 添加
</el-button>
</div>
<div v-if="medicalHistories.length === 0" class="empty-tip">
暂无既往病史记录,如有请点击添加
</div>
<el-tag
v-for="(item, index) in medicalHistories"
:key="index"
closable
size="large"
@close="medicalHistories.splice(index, 1)"
style="margin: 4px"
>
{{ item.disease_name }}
</el-tag>
</div>
<!-- 家族病史 -->
<div class="section">
<div class="section-header">
<h4>家族病史</h4>
<el-button type="primary" text @click="addFamilyHistory">
<el-icon><Plus /></el-icon> 添加
</el-button>
</div>
<div v-if="familyHistories.length === 0" class="empty-tip">
暂无家族病史记录,如有请点击添加
</div>
<el-tag
v-for="(item, index) in familyHistories"
:key="index"
closable
size="large"
@close="familyHistories.splice(index, 1)"
style="margin: 4px"
>
{{ item.relation }} - {{ item.disease_name }}
</el-tag>
</div>
<!-- 过敏史 -->
<div class="section">
<div class="section-header">
<h4>过敏史</h4>
<el-button type="primary" text @click="addAllergy">
<el-icon><Plus /></el-icon> 添加
</el-button>
</div>
<div v-if="allergies.length === 0" class="empty-tip">
暂无过敏记录,如有请点击添加
</div>
<el-tag
v-for="(item, index) in allergies"
:key="index"
closable
size="large"
type="danger"
@close="allergies.splice(index, 1)"
style="margin: 4px"
>
{{ item.allergen }}({{ allergyTypeMap[item.allergy_type] }})
</el-tag>
</div>
<div class="form-actions">
<el-button size="large" @click="$emit('prev')">上一步</el-button>
<el-button type="primary" size="large" :loading="loading" @click="handleSubmit">
完成调查
</el-button>
</div>
<!-- 添加病史对话框 -->
<el-dialog v-model="medicalDialog" title="添加既往病史" width="400px">
<el-form :model="medicalForm" label-width="80px">
<el-form-item label="疾病名称">
<el-input v-model="medicalForm.disease_name" placeholder="如:高血压、糖尿病" />
</el-form-item>
<el-form-item label="疾病类型">
<el-select v-model="medicalForm.disease_type" style="width: 100%">
<el-option label="慢性病" value="chronic" />
<el-option label="手术史" value="surgery" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="medicalDialog = false">取消</el-button>
<el-button type="primary" @click="confirmAddMedical">确定</el-button>
</template>
</el-dialog>
<!-- 添加家族史对话框 -->
<el-dialog v-model="familyDialog" title="添加家族病史" width="400px">
<el-form :model="familyForm" label-width="80px">
<el-form-item label="亲属关系">
<el-select v-model="familyForm.relation" style="width: 100%">
<el-option label="父亲" value="father" />
<el-option label="母亲" value="mother" />
<el-option label="祖父母" value="grandparent" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="疾病名称">
<el-input v-model="familyForm.disease_name" placeholder="如:高血压、糖尿病" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="familyDialog = false">取消</el-button>
<el-button type="primary" @click="confirmAddFamily">确定</el-button>
</template>
</el-dialog>
<!-- 添加过敏对话框 -->
<el-dialog v-model="allergyDialog" title="添加过敏信息" width="400px">
<el-form :model="allergyForm" label-width="80px">
<el-form-item label="过敏类型">
<el-select v-model="allergyForm.allergy_type" style="width: 100%">
<el-option label="药物过敏" value="drug" />
<el-option label="食物过敏" value="food" />
<el-option label="其他过敏" value="other" />
</el-select>
</el-form-item>
<el-form-item label="过敏原">
<el-input v-model="allergyForm.allergen" placeholder="如:青霉素、花生" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="allergyDialog = false">取消</el-button>
<el-button type="primary" @click="confirmAddAllergy">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { submitMedicalHistory, submitFamilyHistory, submitAllergy } from '@/api/survey'
const emit = defineEmits(['prev', 'next'])
const loading = ref(false)
// 病史列表
const medicalHistories = ref<any[]>([])
const familyHistories = ref<any[]>([])
const allergies = ref<any[]>([])
// 对话框
const medicalDialog = ref(false)
const familyDialog = ref(false)
const allergyDialog = ref(false)
// 表单
const medicalForm = reactive({ disease_name: '', disease_type: 'chronic' })
const familyForm = reactive({ relation: 'father', disease_name: '' })
const allergyForm = reactive({ allergy_type: 'drug', allergen: '' })
const allergyTypeMap: Record<string, string> = {
drug: '药物',
food: '食物',
other: '其他',
}
const addMedicalHistory = () => {
medicalForm.disease_name = ''
medicalDialog.value = true
}
const confirmAddMedical = () => {
if (!medicalForm.disease_name) {
ElMessage.warning('请输入疾病名称')
return
}
medicalHistories.value.push({ ...medicalForm })
medicalDialog.value = false
}
const addFamilyHistory = () => {
familyForm.disease_name = ''
familyDialog.value = true
}
const confirmAddFamily = () => {
if (!familyForm.disease_name) {
ElMessage.warning('请输入疾病名称')
return
}
familyHistories.value.push({ ...familyForm })
familyDialog.value = false
}
const addAllergy = () => {
allergyForm.allergen = ''
allergyDialog.value = true
}
const confirmAddAllergy = () => {
if (!allergyForm.allergen) {
ElMessage.warning('请输入过敏原')
return
}
allergies.value.push({ ...allergyForm })
allergyDialog.value = false
}
const handleSubmit = async () => {
loading.value = true
try {
// 提交所有病史数据
for (const item of medicalHistories.value) {
await submitMedicalHistory(item)
}
for (const item of familyHistories.value) {
await submitFamilyHistory(item)
}
for (const item of allergies.value) {
await submitAllergy(item)
}
ElMessage.success('健康调查完成')
emit('next')
} catch (error) {
// 错误已处理
} finally {
loading.value = false
}
}
</script>
<style scoped>
.form-title {
font-size: 18px;
font-weight: 500;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
}
.section {
margin-bottom: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-header h4 {
margin: 0;
font-size: 14px;
color: #333;
}
.empty-tip {
color: #999;
font-size: 13px;
}
.form-actions {
margin-top: 24px;
text-align: right;
}
.form-actions .el-button + .el-button {
margin-left: 12px;
}
</style>
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/api/survey.ts` | 调查 API |
| `src/views/survey/Index.vue` | 调查主页面 |
| `src/components/survey/BasicInfoForm.vue` | 基础信息表单 |
| `src/components/survey/LifestyleForm.vue` | 生活习惯表单 |
| `src/components/survey/HealthStatusForm.vue` | 健康状况表单 |
---
## 验收标准
- [ ] 步骤指示器显示正常
- [ ] 基础信息表单提交成功
- [ ] 生活习惯表单提交成功
- [ ] 病史/过敏信息可添加删除
- [ ] 完成后跳转体质测评页
---
## 预计耗时
40-50 分钟
---
## 下一步
完成后进入 `03-Web前端开发/05-体质辨识页面.md`

691
TODOS/03-Web前端开发/05-体质辨识页面.md

@ -0,0 +1,691 @@
# 05-体质辨识页面
## 目标
实现中医体质辨识问卷页面,包括问卷填写和结果展示。
---
## UI 设计参考
> 参考设计稿:`files/ui/体质页.png`、`files/ui/体质检测.png`、`files/ui/体质分析.png`
### 体质首页布局
| 区域 | 设计要点 |
|------|----------|
| 顶部卡片 | 绿色渐变背景,"中医体质自测" 标题 + 介绍文案 |
| 测试说明 | 白色卡片,3步骤(绿色序号圆圈 + 说明文字) |
| 按钮 | 绿色全宽圆角按钮 "开始测试",圆角 24px |
### 问卷页面布局
| 区域 | 设计要点 |
|------|----------|
| 导航栏 | 返回箭头 + "中医体质辨识自测" + 进度 "1/65" |
| 进度条 | 绿色细条 `#10B981`,高度 4px |
| 问题标签 | 绿色背景 `#DCFCE7`,文字 `#10B981`,"问题N" |
| 问题文字 | 字号 18px,颜色 `#1F2937` |
| 选项按钮 | 白色背景,边框 `#E5E7EB`,圆角 12px,选中高亮绿色 |
| 底部提示 | 浅绿色背景 `#ECFDF5`,带 info 图标 |
### 结果页面布局
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色渐变背景 + 人体轮廓图 + 分享按钮 |
| 体质名称 | 大字体 32px,白色 |
| 分数徽章 | 绿色背景圆角标签 "85分" |
| 雷达图 | 白色卡片,九种体质得分可视化,颜色 `#10B981` |
| 体质特征 | 图标 + 描述文字 |
| 调理建议 | 2×2 网格布局 |
### 调理建议卡片配色
| 类型 | 图标背景 | 图标颜色 |
|------|----------|----------|
| 起居 | `#EDE9FE` | `#8B5CF6` |
| 饮食 | `#CCFBF1` | `#14B8A6` |
| 运动 | `#EDE9FE` | `#8B5CF6` |
| 情志 | `#FCE7F3` | `#EC4899` |
---
## 前置要求
- 健康调查页面完成
- 后端体质接口可用
---
## 实施步骤
### 步骤 1:创建体质 API
创建 `src/api/constitution.ts`
```typescript
import request from './request'
import type { Question, ConstitutionResult } from '@/types'
export const getQuestions = (): Promise<Question[]> => {
return request.get('/constitution/questions')
}
export const submitAssessment = (answers: { question_id: number; score: number }[]): Promise<ConstitutionResult> => {
return request.post('/constitution/submit', { answers })
}
export const getLatestResult = (): Promise<ConstitutionResult> => {
return request.get('/constitution/result')
}
export const getAssessmentHistory = () => {
return request.get('/constitution/history')
}
```
### 步骤 2:创建体质测评主页面
创建 `src/views/constitution/Index.vue`
```vue
<template>
<div class="constitution-page">
<div class="page-header">
<h2>中医体质辨识</h2>
<p>基于《中医体质分类与判定》标准,共 {{ questions.length }} 道题目</p>
</div>
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="!started" class="start-container">
<el-card class="intro-card">
<h3>什么是中医体质辨识?</h3>
<p>
中医体质辨识是根据中医理论,通过对您日常生活习惯、身体感受等方面的调查,
判断您属于九种体质中的哪一种或哪几种,从而为您提供个性化的健康调养建议。
</p>
<h4>九种体质类型</h4>
<div class="constitution-types">
<el-tag v-for="item in constitutionTypes" :key="item.type" size="large">
{{ item.name }}
</el-tag>
</div>
<div class="start-action">
<el-button type="primary" size="large" @click="startAssessment">
开始测评
</el-button>
</div>
</el-card>
</div>
<div v-else class="questionnaire-container">
<el-progress
:percentage="progress"
:format="() => `${currentIndex + 1} / ${questions.length}`"
style="margin-bottom: 20px"
/>
<el-card class="question-card">
<div class="question-type">
{{ constitutionNameMap[currentQuestion.constitution_type] }}
</div>
<div class="question-text">
{{ currentIndex + 1 }}. {{ currentQuestion.question_text }}
</div>
<div class="options">
<el-button
v-for="(option, index) in options"
:key="index"
:type="answers[currentQuestion.id] === index + 1 ? 'primary' : 'default'"
size="large"
@click="selectOption(index + 1)"
>
{{ option }}
</el-button>
</div>
</el-card>
<div class="nav-actions">
<el-button
:disabled="currentIndex === 0"
size="large"
@click="prevQuestion"
>
上一题
</el-button>
<el-button
v-if="currentIndex < questions.length - 1"
type="primary"
size="large"
:disabled="!answers[currentQuestion.id]"
@click="nextQuestion"
>
下一题
</el-button>
<el-button
v-else
type="success"
size="large"
:loading="submitting"
:disabled="!isAllAnswered"
@click="submitAssessmentHandler"
>
提交测评
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { Question } from '@/types'
import { getQuestions, submitAssessment } from '@/api/constitution'
const router = useRouter()
const loading = ref(true)
const started = ref(false)
const submitting = ref(false)
const questions = ref<Question[]>([])
const currentIndex = ref(0)
const answers = ref<Record<number, number>>({})
const options = ['没有', '很少', '有时', '经常', '总是']
const constitutionTypes = [
{ type: 'pinghe', name: '平和质' },
{ type: 'qixu', name: '气虚质' },
{ type: 'yangxu', name: '阳虚质' },
{ type: 'yinxu', name: '阴虚质' },
{ type: 'tanshi', name: '痰湿质' },
{ type: 'shire', name: '湿热质' },
{ type: 'xueyu', name: '血瘀质' },
{ type: 'qiyu', name: '气郁质' },
{ type: 'tebing', name: '特禀质' },
]
const constitutionNameMap: Record<string, string> = {
pinghe: '平和质',
qixu: '气虚质',
yangxu: '阳虚质',
yinxu: '阴虚质',
tanshi: '痰湿质',
shire: '湿热质',
xueyu: '血瘀质',
qiyu: '气郁质',
tebing: '特禀质',
}
const currentQuestion = computed(() => questions.value[currentIndex.value])
const progress = computed(() => {
return Math.round(((currentIndex.value + 1) / questions.value.length) * 100)
})
const isAllAnswered = computed(() => {
return questions.value.every((q) => answers.value[q.id])
})
onMounted(async () => {
try {
questions.value = await getQuestions()
} catch (error) {
ElMessage.error('获取问卷失败')
} finally {
loading.value = false
}
})
const startAssessment = () => {
started.value = true
}
const selectOption = (score: number) => {
answers.value[currentQuestion.value.id] = score
}
const prevQuestion = () => {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
const nextQuestion = () => {
if (currentIndex.value < questions.value.length - 1) {
currentIndex.value++
}
}
const submitAssessmentHandler = async () => {
submitting.value = true
try {
const answerList = Object.entries(answers.value).map(([questionId, score]) => ({
question_id: parseInt(questionId),
score,
}))
await submitAssessment(answerList)
ElMessage.success('测评完成')
router.push('/constitution/result')
} catch (error) {
// 错误已处理
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.constitution-page {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin-bottom: 8px;
}
.page-header p {
color: #666;
}
.loading-container {
padding: 40px;
background: #fff;
border-radius: 8px;
}
.intro-card {
padding: 20px;
}
.intro-card h3 {
margin-bottom: 16px;
}
.intro-card p {
color: #666;
line-height: 1.8;
}
.intro-card h4 {
margin: 24px 0 12px;
}
.constitution-types {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.start-action {
margin-top: 32px;
text-align: center;
}
.question-card {
padding: 30px;
}
.question-type {
display: inline-block;
padding: 4px 12px;
background: #ecf5ff;
color: #409eff;
border-radius: 4px;
font-size: 13px;
margin-bottom: 16px;
}
.question-text {
font-size: 18px;
line-height: 1.6;
margin-bottom: 24px;
}
.options {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.options .el-button {
min-width: 100px;
}
.nav-actions {
margin-top: 24px;
display: flex;
justify-content: space-between;
}
</style>
```
### 步骤 3:创建结果展示页面
创建 `src/views/constitution/Result.vue`
```vue
<template>
<div class="result-page">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<template v-else-if="result">
<!-- 主要体质 -->
<el-card class="primary-card">
<div class="result-header">
<h2>您的体质类型</h2>
<el-tag size="large" type="success">{{ result.primary_constitution.name }}</el-tag>
</div>
<p class="description">{{ result.primary_constitution.description }}</p>
</el-card>
<!-- 体质雷达图 -->
<el-card class="chart-card">
<h3>体质得分分布</h3>
<div ref="chartRef" class="chart-container"></div>
</el-card>
<!-- 次要体质 -->
<el-card v-if="result.secondary_constitutions?.length" class="secondary-card">
<h3>次要体质倾向</h3>
<div class="secondary-list">
<div
v-for="item in result.secondary_constitutions"
:key="item.type"
class="secondary-item"
>
<span class="name">{{ item.name }}</span>
<el-progress
:percentage="item.score"
:stroke-width="10"
:format="() => item.score.toFixed(0) + '分'"
/>
</div>
</div>
</el-card>
<!-- 调养建议 -->
<el-card class="recommendations-card">
<h3>调养建议</h3>
<el-tabs>
<el-tab-pane
v-for="(recs, type) in result.recommendations"
:key="type"
:label="constitutionNameMap[type]"
>
<div class="rec-list">
<div class="rec-item">
<el-icon><Bowl /></el-icon>
<div>
<h4>饮食调养</h4>
<p>{{ recs.diet }}</p>
</div>
</div>
<div class="rec-item">
<el-icon><House /></el-icon>
<div>
<h4>起居调养</h4>
<p>{{ recs.lifestyle }}</p>
</div>
</div>
<div class="rec-item">
<el-icon><Bicycle /></el-icon>
<div>
<h4>运动调养</h4>
<p>{{ recs.exercise }}</p>
</div>
</div>
<div class="rec-item">
<el-icon><Sunny /></el-icon>
<div>
<h4>情志调养</h4>
<p>{{ recs.emotion }}</p>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<div class="actions">
<el-button type="primary" size="large" @click="$router.push('/chat')">
开始 AI 问诊
</el-button>
<el-button size="large" @click="$router.push('/constitution')">
重新测评
</el-button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Bowl, House, Bicycle, Sunny } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import type { ConstitutionResult } from '@/types'
import { getLatestResult } from '@/api/constitution'
const loading = ref(true)
const result = ref<ConstitutionResult | null>(null)
const chartRef = ref<HTMLElement>()
const constitutionNameMap: Record<string, string> = {
pinghe: '平和质',
qixu: '气虚质',
yangxu: '阳虚质',
yinxu: '阴虚质',
tanshi: '痰湿质',
shire: '湿热质',
xueyu: '血瘀质',
qiyu: '气郁质',
tebing: '特禀质',
}
onMounted(async () => {
try {
result.value = await getLatestResult()
await nextTick()
initChart()
} catch (error) {
ElMessage.error('获取结果失败')
} finally {
loading.value = false
}
})
const initChart = () => {
if (!chartRef.value || !result.value) return
const chart = echarts.init(chartRef.value)
const data = result.value.all_scores.map((item) => ({
name: item.name,
value: item.score,
}))
chart.setOption({
radar: {
indicator: data.map((d) => ({ name: d.name, max: 100 })),
radius: '65%',
},
series: [
{
type: 'radar',
data: [
{
value: data.map((d) => d.value),
name: '体质得分',
areaStyle: {
color: 'rgba(64, 158, 255, 0.3)',
},
lineStyle: {
color: '#409eff',
},
itemStyle: {
color: '#409eff',
},
},
],
},
],
})
window.addEventListener('resize', () => chart.resize())
}
</script>
<style scoped>
.result-page {
max-width: 800px;
margin: 0 auto;
}
.loading-container {
padding: 40px;
background: #fff;
border-radius: 8px;
}
.el-card {
margin-bottom: 20px;
}
.primary-card {
text-align: center;
padding: 20px;
}
.result-header {
margin-bottom: 16px;
}
.result-header h2 {
margin-bottom: 12px;
}
.description {
color: #666;
font-size: 15px;
}
.chart-card h3 {
margin-bottom: 16px;
}
.chart-container {
height: 350px;
}
.secondary-card h3 {
margin-bottom: 16px;
}
.secondary-item {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.secondary-item .name {
width: 80px;
flex-shrink: 0;
}
.secondary-item .el-progress {
flex: 1;
}
.recommendations-card h3 {
margin-bottom: 16px;
}
.rec-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.rec-item {
display: flex;
gap: 12px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}
.rec-item .el-icon {
font-size: 24px;
color: #409eff;
flex-shrink: 0;
}
.rec-item h4 {
margin-bottom: 8px;
font-size: 14px;
}
.rec-item p {
color: #666;
font-size: 13px;
line-height: 1.6;
}
.actions {
text-align: center;
padding: 20px 0;
}
.actions .el-button + .el-button {
margin-left: 16px;
}
</style>
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/api/constitution.ts` | 体质 API |
| `src/views/constitution/Index.vue` | 测评主页面 |
| `src/views/constitution/Result.vue` | 结果展示页面 |
---
## 验收标准
- [ ] 问卷题目正确加载
- [ ] 答题进度显示正常
- [ ] 提交后跳转结果页
- [ ] 雷达图正确显示
- [ ] 调养建议展示完整
---
## 预计耗时
35-40 分钟
---
## 下一步
完成后进入 `03-Web前端开发/06-AI对话页面.md`

616
TODOS/03-Web前端开发/06-AI对话页面.md

@ -0,0 +1,616 @@
# 06-AI对话页面
## 目标
实现 AI 健康问诊对话功能,支持多轮对话和对话历史管理。
---
## UI 设计参考
> 参考设计稿:`files/ui/问答页.png`、`files/ui/问答对话.png`
### 对话首页布局
| 区域 | 设计要点 |
|------|----------|
| 导航栏 | "AI健康助手" + 绿色 "在线" 状态 + 更多菜单 |
| AI 欢迎语 | 机器人图标(蓝色背景 `#3B82F6`)+ 灰色气泡 |
| 常见问题 | "常见问题" 标签(灰色)+ 快捷问题按钮(白色圆角) |
| 输入区 | 麦克风图标 + 输入框(灰色背景)+ 绿色发送按钮 |
### 消息气泡样式
| 类型 | 样式 |
|------|------|
| AI 消息 | 左对齐,机器人图标(蓝色 `#3B82F6`),灰色气泡 `#F3F4F6` |
| 用户消息 | 右对齐,用户图标(绿色 `#10B981`),绿色气泡 `#10B981`,白色文字 |
| 时间显示 | 灰色小字 `#9CA3AF`,位于消息下方 |
### 输入区样式
| 元素 | 样式 |
|------|------|
| 麦克风 | 灰色图标 `#9CA3AF` |
| 输入框 | 灰色背景 `#F3F4F6`,圆角 24px,占位符 "请输入您的健康问题..." |
| 发送按钮 | 绿色圆形按钮 `#10B981`,飞机图标 |
### 快捷问题示例
- 我最近总是感觉疲劳怎么办?
- 如何改善睡眠质量?
- 有什么养生建议吗?
- 感冒了应该注意什么?
---
## 前置要求
- 体质测评页面完成
- 后端对话接口可用
---
## 实施步骤
### 步骤 1:创建对话 API
创建 `src/api/conversation.ts`
```typescript
import request from './request'
import type { Conversation, Message } from '@/types'
export const getConversations = (): Promise<Conversation[]> => {
return request.get('/conversations')
}
export const createConversation = (title?: string): Promise<Conversation> => {
return request.post('/conversations', { title })
}
export const getConversation = (id: number): Promise<Conversation & { messages: Message[] }> => {
return request.get(`/conversations/${id}`)
}
export const deleteConversation = (id: number) => {
return request.delete(`/conversations/${id}`)
}
export const sendMessage = (id: number, content: string): Promise<{ reply: string }> => {
return request.post(`/conversations/${id}/messages`, { content })
}
```
### 步骤 2:创建对话列表页面
创建 `src/views/chat/Index.vue`
```vue
<template>
<div class="chat-index">
<div class="chat-header">
<h2>AI 健康问诊</h2>
<el-button type="primary" @click="createNewChat">
<el-icon><Plus /></el-icon>
新建对话
</el-button>
</div>
<div v-if="loading" class="loading">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="conversations.length === 0" class="empty-state">
<el-empty description="暂无对话记录">
<el-button type="primary" @click="createNewChat">开始第一次对话</el-button>
</el-empty>
</div>
<div v-else class="conversation-list">
<div
v-for="conv in conversations"
:key="conv.id"
class="conversation-item"
@click="openChat(conv.id)"
>
<div class="conv-icon">
<el-icon><ChatDotRound /></el-icon>
</div>
<div class="conv-content">
<div class="conv-title">{{ conv.title }}</div>
<div class="conv-time">{{ formatTime(conv.updated_at) }}</div>
</div>
<div class="conv-actions">
<el-button
type="danger"
text
size="small"
@click.stop="deleteChat(conv.id)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, ChatDotRound, Delete } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import type { Conversation } from '@/types'
import { getConversations, createConversation, deleteConversation } from '@/api/conversation'
const router = useRouter()
const loading = ref(true)
const conversations = ref<Conversation[]>([])
onMounted(async () => {
await loadConversations()
})
const loadConversations = async () => {
loading.value = true
try {
conversations.value = await getConversations()
} catch (error) {
// 错误已处理
} finally {
loading.value = false
}
}
const createNewChat = async () => {
try {
const conv = await createConversation()
router.push(`/chat/${conv.id}`)
} catch (error) {
// 错误已处理
}
}
const openChat = (id: number) => {
router.push(`/chat/${id}`)
}
const deleteChat = async (id: number) => {
try {
await ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
type: 'warning',
})
await deleteConversation(id)
ElMessage.success('删除成功')
conversations.value = conversations.value.filter((c) => c.id !== id)
} catch (error) {
// 取消或错误
}
}
const formatTime = (time: string) => {
return dayjs(time).format('MM-DD HH:mm')
}
</script>
<style scoped>
.chat-index {
max-width: 800px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.loading {
background: #fff;
padding: 20px;
border-radius: 8px;
}
.empty-state {
background: #fff;
padding: 60px 20px;
border-radius: 8px;
text-align: center;
}
.conversation-list {
background: #fff;
border-radius: 8px;
overflow: hidden;
}
.conversation-item {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.conversation-item:hover {
background: #f9f9f9;
}
.conversation-item:last-child {
border-bottom: none;
}
.conv-icon {
width: 40px;
height: 40px;
background: #ecf5ff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.conv-icon .el-icon {
font-size: 20px;
color: #409eff;
}
.conv-content {
flex: 1;
}
.conv-title {
font-size: 15px;
color: #333;
margin-bottom: 4px;
}
.conv-time {
font-size: 12px;
color: #999;
}
.conv-actions {
opacity: 0;
transition: opacity 0.2s;
}
.conversation-item:hover .conv-actions {
opacity: 1;
}
</style>
```
### 步骤 3:创建对话详情页面
创建 `src/views/chat/Detail.vue`
```vue
<template>
<div class="chat-detail">
<!-- 消息列表 -->
<div ref="messagesRef" class="messages-container">
<div v-if="loading" class="loading">
<el-skeleton :rows="5" animated />
</div>
<template v-else>
<div
v-for="msg in messages"
:key="msg.id"
:class="['message-item', msg.role]"
>
<div class="avatar">
<el-avatar v-if="msg.role === 'user'" :size="36">
{{ userStore.userInfo?.nickname?.charAt(0) || 'U' }}
</el-avatar>
<el-avatar v-else :size="36" style="background: #409eff">
AI
</el-avatar>
</div>
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(msg.content)"></div>
<div class="message-time">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
<div v-if="sending" class="message-item assistant">
<div class="avatar">
<el-avatar :size="36" style="background: #409eff">AI</el-avatar>
</div>
<div class="message-content">
<div class="message-bubble typing">
<span></span><span></span><span></span>
</div>
</div>
</div>
</template>
</div>
<!-- 输入区域 -->
<div class="input-container">
<el-input
v-model="inputText"
type="textarea"
:rows="2"
placeholder="请描述您的健康问题..."
:disabled="sending"
@keydown.enter.exact.prevent="sendMessage"
/>
<el-button
type="primary"
:loading="sending"
:disabled="!inputText.trim()"
@click="sendMessage"
>
发送
</el-button>
</div>
<!-- 免责声明 -->
<div class="disclaimer">
<el-icon><Warning /></el-icon>
AI 建议仅供参考,不构成医疗诊断。如有不适,请及时就医。
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Warning } from '@element-plus/icons-vue'
import { marked } from 'marked'
import dayjs from 'dayjs'
import { useUserStore } from '@/stores/user'
import type { Message } from '@/types'
import { getConversation, sendMessage as sendMessageApi } from '@/api/conversation'
const route = useRoute()
const userStore = useUserStore()
const loading = ref(true)
const sending = ref(false)
const messages = ref<Message[]>([])
const inputText = ref('')
const messagesRef = ref<HTMLElement>()
const conversationId = parseInt(route.params.id as string)
onMounted(async () => {
await loadMessages()
})
const loadMessages = async () => {
loading.value = true
try {
const data = await getConversation(conversationId)
messages.value = data.messages || []
await nextTick()
scrollToBottom()
} catch (error) {
ElMessage.error('加载对话失败')
} finally {
loading.value = false
}
}
const sendMessage = async () => {
const content = inputText.value.trim()
if (!content || sending.value) return
// 添加用户消息
const userMessage: Message = {
id: Date.now(),
role: 'user',
content,
created_at: new Date().toISOString(),
}
messages.value.push(userMessage)
inputText.value = ''
await nextTick()
scrollToBottom()
// 发送请求
sending.value = true
try {
const res = await sendMessageApi(conversationId, content)
// 添加 AI 回复
const assistantMessage: Message = {
id: Date.now() + 1,
role: 'assistant',
content: res.reply,
created_at: new Date().toISOString(),
}
messages.value.push(assistantMessage)
await nextTick()
scrollToBottom()
} catch (error) {
// 移除用户消息
messages.value.pop()
inputText.value = content
} finally {
sending.value = false
}
}
const scrollToBottom = () => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
}
const formatMessage = (content: string) => {
return marked.parse(content)
}
const formatTime = (time: string) => {
return dayjs(time).format('HH:mm')
}
</script>
<style scoped>
.chat-detail {
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 8px;
overflow: hidden;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.loading {
padding: 20px;
}
.message-item {
display: flex;
margin-bottom: 20px;
}
.message-item.user {
flex-direction: row-reverse;
}
.avatar {
flex-shrink: 0;
}
.message-content {
max-width: 70%;
margin: 0 12px;
}
.message-bubble {
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
}
.user .message-bubble {
background: #409eff;
color: #fff;
border-top-right-radius: 4px;
}
.assistant .message-bubble {
background: #f4f4f5;
color: #333;
border-top-left-radius: 4px;
}
.message-time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.user .message-time {
text-align: right;
}
/* 打字动画 */
.typing {
display: flex;
gap: 4px;
padding: 16px 20px;
}
.typing span {
width: 8px;
height: 8px;
background: #999;
border-radius: 50%;
animation: typing 1s infinite;
}
.typing span:nth-child(2) {
animation-delay: 0.2s;
}
.typing span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.input-container {
display: flex;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #eee;
}
.input-container .el-textarea {
flex: 1;
}
.disclaimer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: #fef0f0;
color: #f56c6c;
font-size: 12px;
}
</style>
```
### 步骤 4:安装 marked 库
```bash
npm install marked
npm install @types/marked -D
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/api/conversation.ts` | 对话 API |
| `src/views/chat/Index.vue` | 对话列表页 |
| `src/views/chat/Detail.vue` | 对话详情页 |
---
## 验收标准
- [ ] 对话列表正确显示
- [ ] 新建对话功能正常
- [ ] 消息发送和接收正常
- [ ] AI 回复正确渲染 Markdown
- [ ] 打字动画效果正常
- [ ] 免责声明显示
---
## 预计耗时
35-40 分钟
---
## 下一步
完成后进入 `03-Web前端开发/07-个人中心页面.md`

588
TODOS/03-Web前端开发/07-个人中心页面.md

@ -0,0 +1,588 @@
# 07-个人中心页面
## 目标
实现个人中心和健康档案管理页面。
---
## UI 设计参考
> 参考设计稿:`files/ui/我的.png`
### 页面布局
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色背景 `#10B981` + "我的" 标题(白色) |
| 用户卡片 | 头像(圆形)+ 姓名 + 基本信息 + 用户ID + 编辑按钮 |
| 健康管理 | "用药情况" 入口(带数量角标 `12条`) |
| 设置列表 | 消息通知、隐私设置、通用设置 |
### 用户卡片样式
| 元素 | 样式 |
|------|------|
| 头像 | 64px 圆形,浅绿色背景 |
| 姓名 | 白色,字号 20px |
| 基本信息 | 白色,格式 "男·28岁·175cm·70kg" |
| 用户ID | 浅白色 `rgba(255,255,255,0.7)` |
| 编辑按钮 | 白色半透明背景,编辑图标 |
### 列表项样式
| 元素 | 样式 |
|------|------|
| 图标背景 | 40px 圆形,各功能不同颜色 |
| 用药情况 | 绿色背景 `#DCFCE7`,图标 `#10B981` |
| 消息通知 | 绿色背景 `#DCFCE7`,铃铛图标 |
| 隐私设置 | 绿色背景 `#DCFCE7`,盾牌图标 |
| 通用设置 | 绿色背景 `#DCFCE7`,齿轮图标 |
| 右箭头 | 灰色 `#9CA3AF` |
| 角标 | 灰色文字 "12条" |
---
## 前置要求
- 前面所有页面完成
- 后端用户接口可用
---
## 实施步骤
### 步骤 1:创建个人中心页面
创建 `src/views/profile/Index.vue`
```vue
<template>
<div class="profile-page">
<el-card class="user-card">
<div class="user-info">
<el-avatar :size="80" :src="userStore.userInfo?.avatar">
{{ userStore.userInfo?.nickname?.charAt(0) || 'U' }}
</el-avatar>
<div class="user-detail">
<h2>{{ userStore.userInfo?.nickname || '用户' }}</h2>
<p>{{ userStore.userInfo?.phone }}</p>
</div>
<el-button type="primary" text @click="showEditDialog = true">
编辑资料
</el-button>
</div>
</el-card>
<el-card class="menu-card">
<div class="menu-list">
<div class="menu-item" @click="$router.push('/health-record')">
<div class="menu-icon">
<el-icon><Document /></el-icon>
</div>
<div class="menu-content">
<span class="menu-title">健康档案</span>
<span class="menu-desc">查看和管理您的健康信息</span>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
<div class="menu-item" @click="$router.push('/constitution/result')">
<div class="menu-icon">
<el-icon><User /></el-icon>
</div>
<div class="menu-content">
<span class="menu-title">体质报告</span>
<span class="menu-desc">查看您的体质辨识结果</span>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
<div class="menu-item" @click="$router.push('/constitution')">
<div class="menu-icon">
<el-icon><Refresh /></el-icon>
</div>
<div class="menu-content">
<span class="menu-title">重新测评</span>
<span class="menu-desc">建议每3-6个月重新测评一次</span>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
<div class="menu-item" @click="showAbout">
<div class="menu-icon">
<el-icon><InfoFilled /></el-icon>
</div>
<div class="menu-content">
<span class="menu-title">关于我们</span>
<span class="menu-desc">了解健康AI助手</span>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
</div>
</el-card>
<div class="logout-container">
<el-button type="danger" text @click="handleLogout">
退出登录
</el-button>
</div>
<!-- 编辑资料对话框 -->
<el-dialog v-model="showEditDialog" title="编辑资料" width="400px">
<el-form :model="editForm" label-width="80px">
<el-form-item label="昵称">
<el-input v-model="editForm.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editForm.email" placeholder="请输入邮箱" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveProfile">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Document,
User,
Refresh,
InfoFilled,
ArrowRight,
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { updateUserProfile } from '@/api/user'
const router = useRouter()
const userStore = useUserStore()
const showEditDialog = ref(false)
const saving = ref(false)
const editForm = reactive({
nickname: '',
email: '',
})
watch(showEditDialog, (val) => {
if (val && userStore.userInfo) {
editForm.nickname = userStore.userInfo.nickname || ''
editForm.email = userStore.userInfo.email || ''
}
})
const saveProfile = async () => {
saving.value = true
try {
await updateUserProfile(editForm)
userStore.userInfo.nickname = editForm.nickname
userStore.userInfo.email = editForm.email
ElMessage.success('保存成功')
showEditDialog.value = false
} catch (error) {
// 错误已处理
} finally {
saving.value = false
}
}
const showAbout = () => {
ElMessageBox.alert(
`健康AI助手是一款智能健康咨询应用,结合中医体质辨识理论,为您提供个性化的健康建议。
版本:1.0.0
开发者:Health AI Team`,
'关于我们',
{ confirmButtonText: '我知道了' }
)
}
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
type: 'warning',
}).then(() => {
userStore.logout()
router.push('/auth/login')
})
}
</script>
<style scoped>
.profile-page {
max-width: 600px;
margin: 0 auto;
}
.user-card {
margin-bottom: 20px;
}
.user-info {
display: flex;
align-items: center;
}
.user-detail {
flex: 1;
margin-left: 20px;
}
.user-detail h2 {
margin-bottom: 4px;
font-size: 20px;
}
.user-detail p {
color: #999;
font-size: 14px;
}
.menu-card {
margin-bottom: 20px;
}
.menu-list {
margin: -20px;
}
.menu-item {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f9f9f9;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-icon {
width: 40px;
height: 40px;
background: #ecf5ff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.menu-icon .el-icon {
font-size: 20px;
color: #409eff;
}
.menu-content {
flex: 1;
}
.menu-title {
display: block;
font-size: 15px;
color: #333;
margin-bottom: 4px;
}
.menu-desc {
font-size: 12px;
color: #999;
}
.logout-container {
text-align: center;
padding: 20px 0;
}
</style>
```
### 步骤 2:创建健康档案页面
创建 `src/views/profile/HealthRecord.vue`
```vue
<template>
<div class="health-record-page">
<div class="page-header">
<el-button text @click="$router.back()">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h2>健康档案</h2>
</div>
<div v-if="loading" class="loading">
<el-skeleton :rows="10" animated />
</div>
<template v-else>
<!-- 基础信息 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>基础信息</span>
<el-button type="primary" text @click="editBasicInfo">编辑</el-button>
</div>
</template>
<el-descriptions :column="2" border v-if="profile.basic_info">
<el-descriptions-item label="姓名">{{ profile.basic_info.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderMap[profile.basic_info.gender] || '-' }}</el-descriptions-item>
<el-descriptions-item label="身高">{{ profile.basic_info.height ? profile.basic_info.height + ' cm' : '-' }}</el-descriptions-item>
<el-descriptions-item label="体重">{{ profile.basic_info.weight ? profile.basic_info.weight + ' kg' : '-' }}</el-descriptions-item>
<el-descriptions-item label="BMI">{{ profile.basic_info.bmi ? profile.basic_info.bmi.toFixed(1) : '-' }}</el-descriptions-item>
<el-descriptions-item label="血型">{{ profile.basic_info.blood_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="职业">{{ profile.basic_info.occupation || '-' }}</el-descriptions-item>
<el-descriptions-item label="地区">{{ profile.basic_info.region || '-' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="暂无基础信息" />
</el-card>
<!-- 体质信息 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>体质信息</span>
<el-button type="primary" text @click="$router.push('/constitution')">重新测评</el-button>
</div>
</template>
<div v-if="profile.constitution" class="constitution-info">
<div class="constitution-main">
<el-tag size="large" type="success">{{ profile.constitution.primary_name }}</el-tag>
<p>{{ profile.constitution.primary_description }}</p>
</div>
<div class="constitution-time">
最近测评时间:{{ profile.constitution.assessed_at }}
</div>
</div>
<el-empty v-else description="暂无体质测评记录">
<el-button type="primary" @click="$router.push('/constitution')">
立即测评
</el-button>
</el-empty>
</el-card>
<!-- 生活习惯 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>生活习惯</span>
<el-button type="primary" text @click="editLifestyle">编辑</el-button>
</div>
</template>
<el-descriptions :column="2" border v-if="profile.lifestyle">
<el-descriptions-item label="入睡时间">{{ profile.lifestyle.sleep_time || '-' }}</el-descriptions-item>
<el-descriptions-item label="起床时间">{{ profile.lifestyle.wake_time || '-' }}</el-descriptions-item>
<el-descriptions-item label="睡眠质量">{{ sleepQualityMap[profile.lifestyle.sleep_quality] || '-' }}</el-descriptions-item>
<el-descriptions-item label="运动频率">{{ exerciseFreqMap[profile.lifestyle.exercise_frequency] || '-' }}</el-descriptions-item>
<el-descriptions-item label="吸烟">{{ profile.lifestyle.is_smoker ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="饮酒">{{ alcoholMap[profile.lifestyle.alcohol_frequency] || '-' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="暂无生活习惯信息" />
</el-card>
<!-- 病史记录 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>既往病史</span>
</div>
</template>
<div v-if="profile.medical_history?.length" class="tag-list">
<el-tag v-for="item in profile.medical_history" :key="item.id" size="large">
{{ item.disease_name }}
</el-tag>
</div>
<el-empty v-else description="暂无病史记录" />
</el-card>
<!-- 过敏信息 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>过敏信息</span>
</div>
</template>
<div v-if="profile.allergy_records?.length" class="tag-list">
<el-tag
v-for="item in profile.allergy_records"
:key="item.id"
size="large"
type="danger"
>
{{ item.allergen }}({{ allergyTypeMap[item.allergy_type] }})
</el-tag>
</div>
<el-empty v-else description="暂无过敏信息" />
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { getHealthProfile } from '@/api/user'
const router = useRouter()
const loading = ref(true)
const profile = ref<any>({})
const genderMap: Record<string, string> = {
male: '男',
female: '女',
}
const sleepQualityMap: Record<string, string> = {
good: '好',
normal: '一般',
poor: '差',
}
const exerciseFreqMap: Record<string, string> = {
never: '从不',
sometimes: '偶尔',
often: '经常',
daily: '每天',
}
const alcoholMap: Record<string, string> = {
never: '从不',
sometimes: '偶尔',
often: '经常',
}
const allergyTypeMap: Record<string, string> = {
drug: '药物',
food: '食物',
other: '其他',
}
onMounted(async () => {
try {
profile.value = await getHealthProfile()
} catch (error) {
// 错误已处理
} finally {
loading.value = false
}
})
const editBasicInfo = () => {
router.push('/survey')
}
const editLifestyle = () => {
router.push('/survey')
}
</script>
<style scoped>
.health-record-page {
max-width: 800px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
}
.loading {
background: #fff;
padding: 20px;
border-radius: 8px;
}
.section-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.constitution-info {
text-align: center;
}
.constitution-main {
margin-bottom: 16px;
}
.constitution-main p {
margin-top: 12px;
color: #666;
}
.constitution-time {
font-size: 13px;
color: #999;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/views/profile/Index.vue` | 个人中心页面 |
| `src/views/profile/HealthRecord.vue` | 健康档案页面 |
---
## 验收标准
- [ ] 个人中心显示用户信息
- [ ] 编辑资料功能正常
- [ ] 健康档案数据正确显示
- [ ] 各菜单跳转正常
- [ ] 退出登录功能正常
---
## 预计耗时
25-30 分钟
---
## 下一步
Web 前端开发完成!进入 `04-APP开发/01-项目结构初始化.md`

277
TODOS/03-Web原型开发/01-项目初始化和模拟数据.md

@ -0,0 +1,277 @@
# 01-项目初始化和模拟数据
## 目标
初始化 Vue 3 项目并搭建模拟数据服务,为原型开发提供完整的数据支持。
---
## 前置要求
- 已完成 Web 前端 Vue 环境搭建
- Node.js 18+
---
## 实施步骤
### 步骤 1:创建 Vue 3 项目
```bash
# 进入项目目录
cd I:/apps/demo/healthApps
# 创建 Vue 3 项目
npm create vite@latest web -- --template vue-ts
# 进入项目
cd web
# 安装依赖
npm install
```
### 步骤 2:安装核心依赖
```bash
# 路由
npm install vue-router@4
# 状态管理
npm install pinia
# UI 组件库
npm install element-plus @element-plus/icons-vue
# HTTP 请求
npm install axios
# 图表
npm install echarts vue-echarts
# 工具
npm install dayjs lodash-es
npm install -D @types/lodash-es
```
### 步骤 3:创建项目目录结构
```
web/
├── src/
│ ├── api/ # API 接口(后续对接用)
│ ├── assets/ # 静态资源
│ ├── components/ # 公共组件
│ │ ├── AppHeader.vue
│ │ ├── AppFooter.vue
│ │ └── Loading.vue
│ ├── mock/ # 模拟数据 ⭐
│ │ ├── index.ts # 统一导出
│ │ ├── user.ts # 用户数据
│ │ ├── constitution.ts # 体质问卷和结果
│ │ ├── chat.ts # AI 对话数据
│ │ └── products.ts # 产品数据
│ ├── router/ # 路由配置
│ │ └── index.ts
│ ├── stores/ # Pinia 状态
│ │ ├── auth.ts
│ │ ├── constitution.ts
│ │ └── chat.ts
│ ├── styles/ # 全局样式
│ │ └── index.scss
│ ├── types/ # TypeScript 类型
│ │ └── index.ts
│ ├── utils/ # 工具函数
│ │ └── index.ts
│ ├── views/ # 页面
│ │ ├── auth/ # 登录相关
│ │ ├── home/ # 首页
│ │ ├── constitution/ # 体质辨识
│ │ ├── chat/ # AI 对话
│ │ └── profile/ # 个人中心
│ ├── App.vue
│ └── main.ts
├── index.html
├── vite.config.ts
└── package.json
```
### 步骤 4:创建类型定义
创建 `src/types/index.ts`(与 APP 端共用类型定义):
```typescript
// 用户类型
export interface User {
id: number
phone: string
nickname: string
avatar: string
surveyCompleted: boolean
}
// 体质类型
export type ConstitutionType =
| 'pinghe' | 'qixu' | 'yangxu' | 'yinxu' | 'tanshi'
| 'shire' | 'xueyu' | 'qiyu' | 'tebing'
// 体质问卷题目
export interface ConstitutionQuestion {
id: number
constitutionType: ConstitutionType
question: string
options: { value: number; label: string }[]
}
// 体质评估结果
export interface ConstitutionResult {
primaryType: ConstitutionType
scores: Record<ConstitutionType, number>
description: string
suggestions: string[]
assessedAt: string
}
// 对话消息
export interface Message {
id: string
role: 'user' | 'assistant'
content: string
createdAt: string
}
// 对话
export interface Conversation {
id: string
title: string
messages: Message[]
createdAt: string
updatedAt: string
}
// 产品
export interface Product {
id: number
name: string
category: string
description: string
efficacy: string
price: number
imageUrl: string
mallUrl: string
}
```
### 步骤 5:创建模拟数据
模拟数据与 APP 端共用同一套逻辑,复制以下文件:
- `src/mock/user.ts`
- `src/mock/constitution.ts`
- `src/mock/chat.ts`
- `src/mock/products.ts`
- `src/mock/index.ts`
> 详细代码参见 APP 端文档 `02-APP原型开发/01-项目初始化和模拟数据.md`
### 步骤 6:配置 Element Plus
更新 `src/main.ts`
```typescript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/index.scss'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
```
### 步骤 7:创建全局样式
创建 `src/styles/index.scss`
```scss
// 主题色
$primary-color: #10B981;
$primary-light: #ECFDF5;
$danger-color: #EF4444;
$warning-color: #F59E0B;
$text-primary: #1F2937;
$text-secondary: #6B7280;
$text-hint: #9CA3AF;
$bg-color: #F3F4F6;
$border-color: #E5E7EB;
// 全局样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: $text-primary;
background-color: $bg-color;
}
// Element Plus 主题覆盖
:root {
--el-color-primary: #{$primary-color};
--el-color-success: #{$primary-color};
}
// 通用类
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
```
---
## 验收标准
- [ ] Vue 3 项目创建成功
- [ ] 所有依赖安装完成
- [ ] 目录结构创建完成
- [ ] 模拟数据文件创建完成
- [ ] Element Plus 配置正常
- [ ] 项目可正常启动
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `03-Web原型开发/02-路由和布局设计.md`

363
TODOS/03-Web原型开发/02-路由和布局设计.md

@ -0,0 +1,363 @@
# 02-路由和布局设计
## 目标
配置 Vue Router 路由系统,实现页面布局和导航。
---
## 前置要求
- 项目结构已初始化
- Vue Router 已安装
- 模拟数据服务已创建
---
## 实施步骤
### 步骤 1:创建路由配置
创建 `src/router/index.ts`
```typescript
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
component: () => import('@/views/layout/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/home/HomeView.vue')
},
{
path: 'chat',
name: 'ChatList',
component: () => import('@/views/chat/ChatListView.vue')
},
{
path: 'chat/:id',
name: 'ChatDetail',
component: () => import('@/views/chat/ChatDetailView.vue')
},
{
path: 'constitution',
name: 'Constitution',
component: () => import('@/views/constitution/ConstitutionView.vue')
},
{
path: 'constitution/test',
name: 'ConstitutionTest',
component: () => import('@/views/constitution/ConstitutionTestView.vue')
},
{
path: 'constitution/result',
name: 'ConstitutionResult',
component: () => import('@/views/constitution/ConstitutionResultView.vue')
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/profile/ProfileView.vue')
},
{
path: 'profile/health-record',
name: 'HealthRecord',
component: () => import('@/views/profile/HealthRecordView.vue')
}
]
}
]
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
next('/login')
} else if (to.path === '/login' && authStore.isLoggedIn) {
next('/')
} else {
next()
}
})
export default router
```
### 步骤 2:创建认证状态 Store
创建 `src/stores/auth.ts`
```typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const isLoggedIn = computed(() => !!token.value)
function login(userData: User) {
user.value = userData
token.value = 'mock-token-' + userData.id
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(userData))
}
function logout() {
user.value = null
token.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
// 初始化时从 localStorage 恢复用户信息
function init() {
const savedUser = localStorage.getItem('user')
if (savedUser && token.value) {
user.value = JSON.parse(savedUser)
}
}
init()
return { user, token, isLoggedIn, login, logout }
})
```
### 步骤 3:创建主布局
创建 `src/views/layout/MainLayout.vue`
```vue
<template>
<el-container class="main-layout">
<!-- 侧边栏 -->
<el-aside width="220px" class="sidebar">
<div class="logo">
<el-icon size="28" color="#10B981"><FirstAidKit /></el-icon>
<span>AI健康助手</span>
</div>
<el-menu
:default-active="activeMenu"
router
class="sidebar-menu"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>首页</span>
</el-menu-item>
<el-menu-item index="/chat">
<el-icon><ChatDotRound /></el-icon>
<span>AI问答</span>
</el-menu-item>
<el-menu-item index="/constitution">
<el-icon><TrendCharts /></el-icon>
<span>体质分析</span>
</el-menu-item>
<el-menu-item index="/profile">
<el-icon><User /></el-icon>
<span>我的</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<el-header class="header">
<div class="header-left">
<span class="greeting">{{ greeting }},{{ authStore.user?.nickname || '用户' }}</span>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<el-avatar :size="36">
{{ authStore.user?.nickname?.charAt(0) || 'U' }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const activeMenu = computed(() => {
const path = route.path
if (path.startsWith('/chat')) return '/chat'
if (path.startsWith('/constitution')) return '/constitution'
if (path.startsWith('/profile')) return '/profile'
return path
})
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 12) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
})
const handleCommand = (command: string) => {
if (command === 'profile') {
router.push('/profile')
} else if (command === 'logout') {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
authStore.logout()
router.push('/login')
})
}
}
</script>
<style scoped lang="scss">
.main-layout {
height: 100vh;
}
.sidebar {
background: #fff;
border-right: 1px solid #E5E7EB;
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-bottom: 1px solid #E5E7EB;
span {
font-size: 18px;
font-weight: 600;
color: #1F2937;
}
}
.sidebar-menu {
border-right: none;
:deep(.el-menu-item.is-active) {
background-color: #ECFDF5;
color: #10B981;
}
}
}
.header {
background: #fff;
border-bottom: 1px solid #E5E7EB;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.greeting {
font-size: 16px;
color: #1F2937;
}
.el-avatar {
cursor: pointer;
background-color: #10B981;
}
}
.main-content {
background: #F3F4F6;
padding: 20px;
}
</style>
```
### 步骤 4:更新 App.vue
```vue
<template>
<router-view />
</template>
<style>
html, body, #app {
height: 100%;
margin: 0;
}
</style>
```
---
## 路由结构
```
/login - 登录页
/ - 主布局
├── / - 首页
├── /chat - 对话列表
├── /chat/:id - 对话详情
├── /constitution - 体质分析首页
├── /constitution/test - 体质问卷
├── /constitution/result - 体质结果
├── /profile - 个人中心
└── /profile/health-record - 健康档案
```
---
## 验收标准
- [ ] 路由配置正确
- [ ] 布局显示正常
- [ ] 导航切换正常
- [ ] 登录状态守卫正常
---
## 预计耗时
25-30 分钟
---
## 下一步
完成后进入 `03-Web原型开发/03-登录页面.md`

271
TODOS/03-Web原型开发/03-登录页面.md

@ -0,0 +1,271 @@
# 03-登录页面(原型)
## 目标
实现 Web 端登录页面原型,使用模拟数据验证登录。
---
## UI 设计参考
> 参考设计稿:`files/ui/登录页.png`
---
## 前置要求
- 路由配置完成
- 模拟数据服务已创建
---
## 实施步骤
### 创建登录页面
创建 `src/views/auth/LoginView.vue`
```vue
<template>
<div class="login-page">
<div class="login-header">
<div class="logo">
<el-icon size="48" color="#fff"><FirstAidKit /></el-icon>
</div>
<h1>欢迎来到AI健康助手</h1>
</div>
<el-card class="login-card">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="form.phone"
placeholder="请输入手机号或邮箱"
:prefix-icon="User"
maxlength="11"
/>
</el-form-item>
<el-form-item label="验证码" prop="code">
<div class="code-row">
<el-input
v-model="form.code"
placeholder="请输入验证码"
:prefix-icon="Lock"
maxlength="6"
/>
<el-button
:disabled="countdown > 0"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
class="login-btn"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-hint">
测试账号:13800138000,验证码:123456
</div>
<div class="login-footer">
<span>还没有账号?</span>
<el-link type="primary">立即注册</el-link>
</div>
<div class="agreement">
<el-link type="primary">《用户协议》</el-link>
<el-link type="primary">《隐私政策》</el-link>
<span>登录即表示您同意我们的</span>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { mockLogin } from '@/mock/user'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref()
const loading = ref(false)
const countdown = ref(0)
const form = reactive({
phone: '13800138000',
code: ''
})
const rules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' }
],
code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码为6位数字', trigger: 'blur' }
]
}
const sendCode = () => {
if (!form.phone || !/^1\d{10}$/.test(form.phone)) {
ElMessage.warning('请输入正确的手机号')
return
}
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
ElMessage.success('验证码已发送,测试验证码为:123456')
}
const handleLogin = async () => {
await formRef.value?.validate()
loading.value = true
try {
const user = await mockLogin(form.phone, form.code)
if (user) {
authStore.login(user)
ElMessage.success('登录成功')
router.push('/')
} else {
ElMessage.error('验证码错误,请输入:123456')
}
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #10B981 0%, #2EC4B6 100%);
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
}
.login-header {
text-align: center;
margin-bottom: 40px;
.logo {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
h1 {
color: #fff;
font-size: 28px;
font-weight: 600;
}
}
.login-card {
width: 100%;
max-width: 400px;
border-radius: 16px;
.code-row {
display: flex;
gap: 12px;
.el-input {
flex: 1;
}
}
.login-btn {
width: 100%;
height: 48px;
border-radius: 24px;
font-size: 16px;
}
.login-hint {
text-align: center;
font-size: 12px;
color: #9CA3AF;
margin-bottom: 16px;
}
.login-footer {
text-align: center;
color: #6B7280;
.el-link {
margin-left: 4px;
}
}
.agreement {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: #9CA3AF;
.el-link {
font-size: 12px;
}
}
}
</style>
```
---
## 验收标准
- [ ] 登录页面 UI 正常显示
- [ ] 验证码倒计时正常
- [ ] 表单验证正常
- [ ] 正确验证码可登录成功
- [ ] 登录成功后跳转到首页
---
## 预计耗时
20-25 分钟
---
## 下一步
完成后进入 `03-Web原型开发/04-首页.md`

391
TODOS/03-Web原型开发/04-首页.md

@ -0,0 +1,391 @@
# 04-首页(原型)
## 目标
实现 Web 首页原型,展示用户体质信息、快捷入口和健康提示。
---
## 前置要求
- 路由和布局配置完成
- 模拟数据服务已创建
- 登录页面完成
---
## 实施步骤
### 创建体质状态 Store
创建 `src/stores/constitution.ts`
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ConstitutionResult } from '@/types'
export const useConstitutionStore = defineStore('constitution', () => {
const result = ref<ConstitutionResult | null>(null)
function setResult(data: ConstitutionResult) {
result.value = data
localStorage.setItem('constitution_result', JSON.stringify(data))
}
function clearResult() {
result.value = null
localStorage.removeItem('constitution_result')
}
// 初始化时从 localStorage 恢复
function init() {
const saved = localStorage.getItem('constitution_result')
if (saved) {
result.value = JSON.parse(saved)
}
}
init()
return { result, setResult, clearResult }
})
```
### 创建首页
创建 `src/views/home/HomeView.vue`
```vue
<template>
<div class="home-page">
<!-- 体质卡片 -->
<el-card class="constitution-card" :body-style="{ padding: '24px' }">
<template v-if="constitutionStore.result">
<div class="constitution-header">
<span class="label">我的体质</span>
<el-link type="primary" @click="router.push('/constitution/result')">
查看详情 →
</el-link>
</div>
<div class="constitution-body">
<el-icon size="40" color="#10B981"><TrendCharts /></el-icon>
<div class="constitution-info">
<h2>{{ constitutionNames[constitutionStore.result.primaryType] }}</h2>
<p>{{ constitutionDescriptions[constitutionStore.result.primaryType].description }}</p>
</div>
</div>
</template>
<template v-else>
<div class="no-constitution" @click="router.push('/constitution')">
<el-icon size="48" color="#9CA3AF"><Document /></el-icon>
<p>还未进行体质测试</p>
<span>点击开始测试,了解您的体质类型</span>
</div>
</template>
</el-card>
<!-- 快捷入口 -->
<div class="quick-actions">
<el-card
v-for="action in quickActions"
:key="action.label"
class="action-card"
shadow="hover"
@click="action.onClick"
>
<div class="action-icon" :style="{ backgroundColor: action.bgColor }">
<el-icon :size="24" :color="action.color">
<component :is="action.icon" />
</el-icon>
</div>
<div class="action-text">
<h4>{{ action.label }}</h4>
<p>{{ action.desc }}</p>
</div>
</el-card>
</div>
<!-- 健康提示 -->
<el-card class="tip-card">
<div class="tip-content">
<el-icon size="24" color="#F59E0B"><Sunrise /></el-icon>
<div class="tip-text">
<h4>今日健康提示</h4>
<p>{{ healthTip }}</p>
</div>
</div>
</el-card>
<!-- 推荐产品 -->
<el-card class="products-card">
<template #header>
<div class="products-header">
<span>{{ constitutionStore.result ? '适合您的调养产品' : '热门保健品' }}</span>
<el-link type="primary" @click="openMall">查看更多 →</el-link>
</div>
</template>
<div class="products-list">
<div
v-for="product in recommendedProducts"
:key="product.id"
class="product-item"
@click="openMall(product.mallUrl)"
>
<div class="product-image">
<el-icon size="32" color="#10B981"><FirstAidKit /></el-icon>
</div>
<p class="product-name">{{ product.name }}</p>
<p class="product-price">¥{{ product.price }}</p>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ChatDotRound, TrendCharts, Document, Sunrise, FirstAidKit, Shop } from '@element-plus/icons-vue'
import { useConstitutionStore } from '@/stores/constitution'
import { constitutionNames, constitutionDescriptions } from '@/mock/constitution'
import { getProductsByConstitution, mockProducts } from '@/mock/products'
const router = useRouter()
const constitutionStore = useConstitutionStore()
const quickActions = [
{
icon: ChatDotRound,
label: 'AI问诊',
desc: '24小时智能健康问答',
color: '#3B82F6',
bgColor: '#DBEAFE',
onClick: () => router.push('/chat')
},
{
icon: TrendCharts,
label: '体质测试',
desc: '科学分析您的体质类型',
color: '#10B981',
bgColor: '#ECFDF5',
onClick: () => router.push('/constitution')
},
{
icon: Document,
label: '健康档案',
desc: '查看个人健康记录',
color: '#8B5CF6',
bgColor: '#EDE9FE',
onClick: () => router.push('/profile/health-record')
},
{
icon: Shop,
label: '健康商城',
desc: '选购调养保健品',
color: '#F59E0B',
bgColor: '#FEF3C7',
onClick: () => window.open('https://mall.example.com')
}
]
const healthTip = computed(() => {
if (constitutionStore.result) {
return constitutionDescriptions[constitutionStore.result.primaryType].suggestions[0]
}
return '保持良好的作息习惯,每天喝足8杯水,适当运动有益身心健康。'
})
const recommendedProducts = computed(() => {
if (constitutionStore.result) {
return getProductsByConstitution(constitutionStore.result.primaryType)
}
return mockProducts.slice(0, 4)
})
const openMall = (url?: string) => {
window.open(url || 'https://mall.example.com')
}
</script>
<style scoped lang="scss">
.home-page {
display: grid;
gap: 20px;
}
.constitution-card {
.constitution-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.label {
color: #6B7280;
}
}
.constitution-body {
display: flex;
align-items: center;
gap: 16px;
h2 {
font-size: 24px;
color: #1F2937;
margin-bottom: 4px;
}
p {
color: #6B7280;
font-size: 14px;
}
}
.no-constitution {
text-align: center;
padding: 32px;
cursor: pointer;
&:hover {
background: #F9FAFB;
border-radius: 8px;
}
p {
margin: 12px 0 4px;
color: #6B7280;
}
span {
color: #9CA3AF;
font-size: 14px;
}
}
}
.quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.action-card {
cursor: pointer;
text-align: center;
padding: 8px;
.action-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 12px;
}
h4 {
font-size: 15px;
color: #1F2937;
margin-bottom: 4px;
}
p {
font-size: 12px;
color: #9CA3AF;
}
}
}
.tip-card {
background: #FFFBEB;
.tip-content {
display: flex;
align-items: flex-start;
gap: 12px;
h4 {
color: #92400E;
margin-bottom: 4px;
}
p {
color: #B45309;
font-size: 14px;
line-height: 1.5;
}
}
}
.products-card {
.products-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.products-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.product-item {
text-align: center;
padding: 16px;
border-radius: 12px;
cursor: pointer;
&:hover {
background: #F9FAFB;
}
.product-image {
width: 64px;
height: 64px;
background: #ECFDF5;
border-radius: 32px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px;
}
.product-name {
font-size: 13px;
color: #1F2937;
margin-bottom: 4px;
}
.product-price {
color: #EF4444;
font-weight: 600;
}
}
}
}
</style>
```
---
## 验收标准
- [ ] 首页 UI 正常显示
- [ ] 体质卡片显示正确
- [ ] 快捷入口点击跳转正常
- [ ] 健康提示显示正常
- [ ] 推荐产品显示正常
---
## 预计耗时
30-35 分钟
---
## 下一步
完成后进入 `03-Web原型开发/05-体质辨识页面.md`

626
TODOS/03-Web原型开发/05-体质辨识页面.md

@ -0,0 +1,626 @@
# 05-体质辨识页面(原型)
## 目标
实现 Web 端中医体质辨识问卷和结果展示,使用本地模拟数据计算结果。
---
## 页面组成
1. **体质首页** - 介绍页面,引导用户开始测试
2. **问卷页面** - 60道题目,逐题作答
3. **结果页面** - 显示体质类型、雷达图、调养建议
---
## 前置要求
- 路由配置完成
- 模拟数据服务已创建
---
## 实施步骤
### 步骤 1:体质首页
创建 `src/views/constitution/ConstitutionView.vue`
```vue
<template>
<div class="constitution-page">
<!-- 已有结果提示 -->
<el-alert
v-if="constitutionStore.result"
type="success"
:closable="false"
class="result-alert"
>
<template #title>
<div class="alert-content">
<span>您已完成体质测评,当前体质:<strong>{{ constitutionNames[constitutionStore.result.primaryType] }}</strong></span>
<el-button type="primary" link @click="router.push('/constitution/result')">
查看详细报告 →
</el-button>
</div>
</template>
</el-alert>
<!-- 介绍卡片 -->
<el-card class="intro-card">
<div class="intro-header">
<h2>中医体质自测</h2>
<p>通过科学的问卷调查,分析您的体质类型,为您提供个性化的健康建议。</p>
</div>
</el-card>
<!-- 步骤说明 -->
<el-card class="steps-card">
<h3>测试说明</h3>
<el-steps :active="0" direction="vertical">
<el-step title="回答65个问题" description="根据您的真实情况选择最符合的答案" />
<el-step title="获取分析报告" description="系统将为您分析体质类型并提供建议" />
<el-step title="个性化建议" description="根据结果提供针对性的健康建议" />
</el-steps>
</el-card>
<!-- 开始按钮 -->
<el-button
type="primary"
size="large"
class="start-btn"
@click="router.push('/constitution/test')"
>
{{ constitutionStore.result ? '重新测评' : '开始测试' }}
</el-button>
<p class="note">建议每3-6个月重新测评一次,以跟踪体质变化</p>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useConstitutionStore } from '@/stores/constitution'
import { constitutionNames } from '@/mock/constitution'
const router = useRouter()
const constitutionStore = useConstitutionStore()
</script>
<style scoped lang="scss">
.constitution-page {
max-width: 600px;
margin: 0 auto;
}
.result-alert {
margin-bottom: 20px;
.alert-content {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.intro-card {
margin-bottom: 20px;
background: linear-gradient(135deg, #10B981 0%, #2EC4B6 100%);
color: #fff;
.intro-header {
text-align: center;
padding: 20px;
h2 {
font-size: 24px;
margin-bottom: 8px;
}
p {
opacity: 0.9;
}
}
}
.steps-card {
margin-bottom: 20px;
h3 {
margin-bottom: 20px;
}
}
.start-btn {
width: 100%;
height: 50px;
border-radius: 25px;
font-size: 16px;
}
.note {
text-align: center;
color: #9CA3AF;
font-size: 13px;
margin-top: 16px;
}
</style>
```
### 步骤 2:问卷页面
创建 `src/views/constitution/ConstitutionTestView.vue`
```vue
<template>
<div class="test-page">
<!-- 进度条 -->
<el-card class="progress-card">
<div class="progress-header">
<span>第 {{ currentIndex + 1 }} 题 / 共 {{ questions.length }} 题</span>
</div>
<el-progress
:percentage="progress"
:stroke-width="8"
:show-text="false"
color="#10B981"
/>
</el-card>
<!-- 问题卡片 -->
<el-card class="question-card">
<div class="question-label">
<el-tag type="success">问题{{ currentIndex + 1 }}</el-tag>
</div>
<h3 class="question-text">{{ currentQuestion.question }}</h3>
<div class="options">
<div
v-for="option in currentQuestion.options"
:key="option.value"
class="option-item"
:class="{ active: answers[currentQuestion.id] === option.value }"
@click="selectOption(option.value)"
>
{{ option.label }}
</div>
</div>
</el-card>
<!-- 提示 -->
<el-alert type="info" :closable="false" class="tip-alert">
请根据您最近三个月的实际感受如实回答,系统将根据您的回答生成专属的中医体质报告。
</el-alert>
<!-- 导航按钮 -->
<div class="nav-buttons">
<el-button size="large" :disabled="currentIndex === 0" @click="handlePrev">
上一题
</el-button>
<el-button
v-if="!isLastQuestion"
type="primary"
size="large"
:disabled="!answers[currentQuestion.id]"
@click="handleNext"
>
下一题
</el-button>
<el-button
v-else
type="primary"
size="large"
:loading="submitting"
@click="handleSubmit"
>
提交
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useConstitutionStore } from '@/stores/constitution'
import { constitutionQuestions, calculateConstitution } from '@/mock/constitution'
const router = useRouter()
const constitutionStore = useConstitutionStore()
const questions = constitutionQuestions
const currentIndex = ref(0)
const answers = ref<Record<number, number>>({})
const submitting = ref(false)
const currentQuestion = computed(() => questions[currentIndex.value])
const progress = computed(() => ((currentIndex.value + 1) / questions.length) * 100)
const isLastQuestion = computed(() => currentIndex.value === questions.length - 1)
const selectOption = (value: number) => {
answers.value[currentQuestion.value.id] = value
}
const handlePrev = () => {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
const handleNext = () => {
if (!answers.value[currentQuestion.value.id]) {
ElMessage.warning('请选择一个选项')
return
}
if (currentIndex.value < questions.length - 1) {
currentIndex.value++
}
}
const handleSubmit = () => {
if (Object.keys(answers.value).length < questions.length) {
ElMessage.warning('请完成所有题目')
return
}
submitting.value = true
// 模拟提交延迟
setTimeout(() => {
const result = calculateConstitution(answers.value)
constitutionStore.setResult(result)
submitting.value = false
router.push('/constitution/result')
}, 1000)
}
</script>
<style scoped lang="scss">
.test-page {
max-width: 600px;
margin: 0 auto;
}
.progress-card {
margin-bottom: 20px;
.progress-header {
text-align: center;
margin-bottom: 12px;
color: #6B7280;
}
}
.question-card {
margin-bottom: 20px;
.question-label {
margin-bottom: 16px;
}
.question-text {
font-size: 18px;
line-height: 1.6;
margin-bottom: 24px;
}
.options {
display: flex;
flex-direction: column;
gap: 12px;
.option-item {
padding: 16px 20px;
border: 1px solid #E5E7EB;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #10B981;
background: #ECFDF5;
}
&.active {
border-color: #10B981;
background: #10B981;
color: #fff;
}
}
}
}
.tip-alert {
margin-bottom: 20px;
}
.nav-buttons {
display: flex;
gap: 16px;
.el-button {
flex: 1;
height: 48px;
}
}
</style>
```
### 步骤 3:结果页面
创建 `src/views/constitution/ConstitutionResultView.vue`
```vue
<template>
<div class="result-page" v-if="constitutionStore.result">
<!-- 主体质卡片 -->
<el-card class="primary-card">
<div class="primary-content">
<h2>体质分析报告</h2>
<p class="sub-title">您的主体质倾向</p>
<div class="primary-type">
<span class="type-name">{{ constitutionNames[constitutionStore.result.primaryType] }}</span>
<el-tag type="success" size="large">{{ constitutionStore.result.scores[constitutionStore.result.primaryType] }}分</el-tag>
</div>
<p class="status-text">体质状态良好,请继续保持</p>
</div>
</el-card>
<!-- 体质雷达图 -->
<el-card class="chart-card">
<template #header>
<div class="card-header">
<el-icon><TrendCharts /></el-icon>
<span>体质雷达图</span>
</div>
</template>
<div class="chart-container" ref="chartRef"></div>
</el-card>
<!-- 体质特征 -->
<el-card class="features-card">
<template #header>
<div class="card-header">
<el-icon><Document /></el-icon>
<span>体质特征</span>
</div>
</template>
<p class="features-text">{{ info.description }}</p>
<div class="features-tags">
<el-tag v-for="(feature, index) in info.features" :key="index" type="info">
{{ feature }}
</el-tag>
</div>
</el-card>
<!-- 调理建议 -->
<el-card class="suggestions-card">
<template #header>调理建议</template>
<el-row :gutter="16">
<el-col :span="12" v-for="(suggestion, index) in info.suggestions" :key="index">
<div class="suggestion-item">
<el-icon size="20" :color="suggestionIcons[index % 4].color">
<component :is="suggestionIcons[index % 4].icon" />
</el-icon>
<p>{{ suggestion }}</p>
</div>
</el-col>
</el-row>
</el-card>
<!-- 操作按钮 -->
<div class="actions">
<el-button type="primary" size="large" @click="router.push('/chat')">
咨询AI助手
</el-button>
<el-button size="large" @click="router.push('/constitution/test')">
重新测评
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import { TrendCharts, Document, Sunny, Bowl, Football, Heart } from '@element-plus/icons-vue'
import { useConstitutionStore } from '@/stores/constitution'
import { constitutionNames, constitutionDescriptions } from '@/mock/constitution'
const router = useRouter()
const constitutionStore = useConstitutionStore()
const chartRef = ref<HTMLElement>()
const info = computed(() => {
if (!constitutionStore.result) return { description: '', features: [], suggestions: [] }
return constitutionDescriptions[constitutionStore.result.primaryType]
})
const suggestionIcons = [
{ icon: Sunny, color: '#8B5CF6' },
{ icon: Bowl, color: '#14B8A6' },
{ icon: Football, color: '#8B5CF6' },
{ icon: Heart, color: '#EC4899' }
]
onMounted(() => {
initChart()
})
watch(() => constitutionStore.result, () => {
initChart()
})
const initChart = () => {
if (!chartRef.value || !constitutionStore.result) return
const chart = echarts.init(chartRef.value)
const scores = constitutionStore.result.scores
const data = Object.entries(scores).map(([type, score]) => ({
name: constitutionNames[type as keyof typeof constitutionNames],
value: score
}))
chart.setOption({
radar: {
indicator: data.map(d => ({ name: d.name, max: 100 })),
shape: 'polygon',
splitNumber: 4,
axisName: {
color: '#6B7280'
}
},
series: [{
type: 'radar',
data: [{
value: data.map(d => d.value),
areaStyle: {
color: 'rgba(16, 185, 129, 0.2)'
},
lineStyle: {
color: '#10B981'
},
itemStyle: {
color: '#10B981'
}
}]
}]
})
}
</script>
<style scoped lang="scss">
.result-page {
max-width: 800px;
margin: 0 auto;
}
.primary-card {
margin-bottom: 20px;
background: linear-gradient(135deg, #10B981 0%, #2EC4B6 100%);
color: #fff;
.primary-content {
text-align: center;
padding: 20px;
h2 {
font-size: 20px;
margin-bottom: 8px;
}
.sub-title {
opacity: 0.8;
margin-bottom: 16px;
}
.primary-type {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
.type-name {
font-size: 36px;
font-weight: bold;
}
}
.status-text {
background: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
display: inline-block;
}
}
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.chart-card {
margin-bottom: 20px;
.chart-container {
height: 300px;
}
}
.features-card {
margin-bottom: 20px;
.features-text {
margin-bottom: 16px;
line-height: 1.8;
color: #4B5563;
}
.features-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.suggestions-card {
margin-bottom: 20px;
.suggestion-item {
background: #F9FAFB;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 12px;
p {
flex: 1;
color: #4B5563;
line-height: 1.6;
}
}
}
.actions {
display: flex;
gap: 16px;
.el-button {
flex: 1;
height: 48px;
}
}
</style>
```
---
## 验收标准
- [ ] 体质首页正常显示
- [ ] 问卷60题可完整答题
- [ ] 进度条显示正确
- [ ] 提交后本地计算结果
- [ ] 雷达图显示正常
- [ ] 调理建议完整显示
---
## 预计耗时
45-55 分钟
---
## 下一步
完成后进入 `03-Web原型开发/06-AI对话页面.md`

573
TODOS/03-Web原型开发/06-AI对话页面.md

@ -0,0 +1,573 @@
# 06-AI对话页面(原型)
## 目标
实现 Web 端 AI 健康问诊对话功能,使用模拟数据模拟多轮对话效果。
---
## 前置要求
- 路由配置完成
- 模拟数据服务已创建
---
## 实施步骤
### 步骤 1:创建对话状态 Store
创建 `src/stores/chat.ts`
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Conversation, Message } from '@/types'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>([])
function addConversation(conv: Conversation) {
conversations.value.unshift(conv)
saveToStorage()
}
function deleteConversation(id: string) {
conversations.value = conversations.value.filter(c => c.id !== id)
saveToStorage()
}
function addMessage(convId: string, message: Message) {
const conv = conversations.value.find(c => c.id === convId)
if (conv) {
conv.messages.push(message)
conv.updatedAt = new Date().toISOString()
// 更新标题为第一条用户消息
if (message.role === 'user' && conv.messages.filter(m => m.role === 'user').length === 1) {
conv.title = message.content.slice(0, 20) + (message.content.length > 20 ? '...' : '')
}
saveToStorage()
}
}
function saveToStorage() {
localStorage.setItem('conversations', JSON.stringify(conversations.value))
}
function init() {
const saved = localStorage.getItem('conversations')
if (saved) {
conversations.value = JSON.parse(saved)
}
}
init()
return { conversations, addConversation, deleteConversation, addMessage }
})
```
### 步骤 2:对话列表页面
创建 `src/views/chat/ChatListView.vue`
```vue
<template>
<div class="chat-list-page">
<div class="page-header">
<h2>AI问答</h2>
<el-button type="primary" @click="createConversation">
<el-icon><Plus /></el-icon>
新建对话
</el-button>
</div>
<div v-if="chatStore.conversations.length === 0" class="empty-state">
<el-empty description="暂无对话记录">
<el-button type="primary" @click="createConversation">开始第一次对话</el-button>
</el-empty>
</div>
<div v-else class="conversation-list">
<el-card
v-for="conv in chatStore.conversations"
:key="conv.id"
class="conversation-item"
shadow="hover"
@click="router.push(`/chat/${conv.id}`)"
>
<div class="conv-content">
<div class="conv-icon">
<el-icon size="24" color="#10B981"><ChatDotRound /></el-icon>
</div>
<div class="conv-info">
<h4>{{ conv.title }}</h4>
<p>{{ formatTime(conv.updatedAt) }}</p>
</div>
<el-button
type="danger"
:icon="Delete"
circle
size="small"
@click.stop="handleDelete(conv.id)"
/>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Plus, ChatDotRound, Delete } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import { useChatStore } from '@/stores/chat'
const router = useRouter()
const chatStore = useChatStore()
const formatTime = (time: string) => dayjs(time).format('MM-DD HH:mm')
const createConversation = () => {
const newConv = {
id: Date.now().toString(),
title: '新对话',
messages: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
chatStore.addConversation(newConv)
router.push(`/chat/${newConv.id}`)
}
const handleDelete = (id: string) => {
ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
chatStore.deleteConversation(id)
})
}
</script>
<style scoped lang="scss">
.chat-list-page {
max-width: 800px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
font-size: 20px;
}
}
.empty-state {
padding: 60px 0;
}
.conversation-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.conversation-item {
cursor: pointer;
.conv-content {
display: flex;
align-items: center;
gap: 16px;
}
.conv-icon {
width: 48px;
height: 48px;
background: #ECFDF5;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.conv-info {
flex: 1;
h4 {
font-size: 15px;
margin-bottom: 4px;
}
p {
font-size: 12px;
color: #9CA3AF;
}
}
}
</style>
```
### 步骤 3:对话详情页面
创建 `src/views/chat/ChatDetailView.vue`
```vue
<template>
<div class="chat-detail-page">
<!-- 消息列表 -->
<div class="message-list" ref="messageListRef">
<!-- 欢迎消息 -->
<div v-if="messages.length === 0" class="welcome-message">
<div class="welcome-avatar">
<el-icon size="32" color="#3B82F6"><Service /></el-icon>
</div>
<div class="welcome-content">
<h3>AI健康助手</h3>
<p>您好!我是您的AI健康助手。我可以为您提供健康咨询、疾病预防建议、用药指导等服务。请问有什么可以帮助您的吗?</p>
</div>
</div>
<!-- 常见问题 -->
<div v-if="messages.length === 0" class="quick-questions">
<p class="label">常见问题</p>
<div class="question-list">
<el-button
v-for="q in quickQuestions"
:key="q"
round
@click="sendQuickQuestion(q)"
>
{{ q }}
</el-button>
</div>
</div>
<!-- 消息列表 -->
<div
v-for="msg in messages"
:key="msg.id"
class="message-item"
:class="msg.role"
>
<div class="message-avatar">
<el-icon v-if="msg.role === 'assistant'" size="20" color="#3B82F6">
<Service />
</el-icon>
<span v-else>{{ authStore.user?.nickname?.charAt(0) || 'U' }}</span>
</div>
<div class="message-bubble">
<div class="message-content" v-html="formatContent(msg.content)"></div>
<div class="message-time">{{ formatTime(msg.createdAt) }}</div>
</div>
</div>
<!-- 输入中提示 -->
<div v-if="sending" class="typing-indicator">
<el-icon class="is-loading"><Loading /></el-icon>
<span>AI 正在思考...</span>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<el-input
v-model="inputText"
type="textarea"
:rows="2"
placeholder="请输入您的健康问题..."
:disabled="sending"
@keydown.enter.exact.prevent="handleSend"
/>
<el-button
type="primary"
:icon="Promotion"
circle
size="large"
:disabled="!inputText.trim() || sending"
@click="handleSend"
/>
</div>
<!-- 免责声明 -->
<div class="disclaimer">
AI 建议仅供参考,不构成医疗诊断,如有需要请就医
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Service, Promotion, Loading } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { mockAIReply } from '@/mock/chat'
import type { Message } from '@/types'
const route = useRoute()
const chatStore = useChatStore()
const authStore = useAuthStore()
const messageListRef = ref<HTMLElement>()
const conversationId = route.params.id as string
const inputText = ref('')
const sending = ref(false)
const messages = computed(() => {
const conv = chatStore.conversations.find(c => c.id === conversationId)
return conv?.messages || []
})
const quickQuestions = [
'我最近总是感觉疲劳怎么办?',
'如何改善睡眠质量?',
'有什么养生建议吗?',
'感冒了应该注意什么?'
]
const formatTime = (time: string) => dayjs(time).format('HH:mm')
const formatContent = (content: string) => {
return content.replace(/\n/g, '<br>')
}
const scrollToBottom = () => {
nextTick(() => {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
})
}
const sendQuickQuestion = (question: string) => {
inputText.value = question
handleSend()
}
const handleSend = async () => {
const content = inputText.value.trim()
if (!content || sending.value) return
// 添加用户消息
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content,
createdAt: new Date().toISOString()
}
chatStore.addMessage(conversationId, userMessage)
inputText.value = ''
scrollToBottom()
// 模拟 AI 回复
sending.value = true
try {
const reply = await mockAIReply(content)
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: reply,
createdAt: new Date().toISOString()
}
chatStore.addMessage(conversationId, assistantMessage)
scrollToBottom()
} finally {
sending.value = false
}
}
onMounted(() => {
scrollToBottom()
})
</script>
<style scoped lang="scss">
.chat-detail-page {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
max-width: 800px;
margin: 0 auto;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #fff;
border-radius: 12px;
margin-bottom: 16px;
}
.welcome-message {
display: flex;
gap: 12px;
margin-bottom: 24px;
.welcome-avatar {
width: 48px;
height: 48px;
background: #DBEAFE;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-content {
flex: 1;
background: #F3F4F6;
padding: 16px;
border-radius: 12px;
h3 {
margin-bottom: 8px;
}
p {
color: #4B5563;
line-height: 1.6;
}
}
}
.quick-questions {
margin-bottom: 24px;
.label {
text-align: center;
color: #9CA3AF;
margin-bottom: 12px;
}
.question-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
}
.message-item {
display: flex;
gap: 12px;
margin-bottom: 20px;
&.user {
flex-direction: row-reverse;
.message-avatar {
background: #10B981;
color: #fff;
}
.message-bubble {
background: #10B981;
color: #fff;
border-bottom-right-radius: 4px;
.message-time {
color: rgba(255, 255, 255, 0.8);
}
}
}
&.assistant {
.message-avatar {
background: #DBEAFE;
}
.message-bubble {
background: #F3F4F6;
border-bottom-left-radius: 4px;
}
}
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message-bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 16px;
.message-content {
line-height: 1.6;
}
.message-time {
font-size: 11px;
color: #9CA3AF;
margin-top: 8px;
}
}
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
color: #9CA3AF;
padding: 8px;
}
.input-area {
display: flex;
gap: 12px;
align-items: flex-end;
.el-textarea {
flex: 1;
}
}
.disclaimer {
text-align: center;
font-size: 12px;
color: #EF4444;
background: #FEF2F2;
padding: 8px;
border-radius: 8px;
margin-top: 12px;
}
</style>
```
---
## 验收标准
- [ ] 对话列表正常显示
- [ ] 新建对话正常
- [ ] 删除对话正常
- [ ] 消息发送和模拟回复正常
- [ ] 快捷问题点击正常
- [ ] 免责声明显示
---
## 预计耗时
40-50 分钟
---
## 下一步
完成后进入 `03-Web原型开发/07-个人中心页面.md`

462
TODOS/03-Web原型开发/07-个人中心页面.md

@ -0,0 +1,462 @@
# 07-个人中心页面(原型)
## 目标
实现 Web 端个人中心和健康档案管理页面原型。
---
## 前置要求
- 路由配置完成
- 认证状态 Store 已创建
- 体质状态 Store 已创建
---
## 实施步骤
### 步骤 1:个人中心页面
创建 `src/views/profile/ProfileView.vue`
```vue
<template>
<div class="profile-page">
<!-- 用户信息卡片 -->
<el-card class="user-card">
<div class="user-info">
<el-avatar :size="80">
{{ authStore.user?.nickname?.charAt(0) || 'U' }}
</el-avatar>
<div class="user-text">
<h2>{{ authStore.user?.nickname || '用户' }}</h2>
<p>{{ authStore.user?.phone }}</p>
<el-tag v-if="constitutionStore.result" type="success">
{{ constitutionNames[constitutionStore.result.primaryType] }}
</el-tag>
</div>
<el-button :icon="Edit" circle @click="handleEdit" />
</div>
</el-card>
<!-- 健康管理 -->
<el-card class="menu-card">
<template #header>健康管理</template>
<div class="menu-list">
<div class="menu-item" @click="router.push('/profile/health-record')">
<div class="menu-icon" style="background: #ECFDF5">
<el-icon size="20" color="#10B981"><Document /></el-icon>
</div>
<div class="menu-text">
<h4>健康档案</h4>
<p>查看和管理您的健康信息</p>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
<el-divider />
<div class="menu-item" @click="router.push('/constitution/result')">
<div class="menu-icon" style="background: #EDE9FE">
<el-icon size="20" color="#8B5CF6"><TrendCharts /></el-icon>
</div>
<div class="menu-text">
<h4>体质报告</h4>
<p>{{ constitutionStore.result ? `当前体质:${constitutionNames[constitutionStore.result.primaryType]}` : '暂无测评记录' }}</p>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
<el-divider />
<div class="menu-item" @click="router.push('/chat')">
<div class="menu-icon" style="background: #DBEAFE">
<el-icon size="20" color="#3B82F6"><ChatDotRound /></el-icon>
</div>
<div class="menu-text">
<h4>对话历史</h4>
<p>查看AI咨询记录</p>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
</div>
</el-card>
<!-- 其他设置 -->
<el-card class="menu-card">
<template #header>其他</template>
<div class="menu-list">
<div class="menu-item" @click="openMall">
<div class="menu-icon" style="background: #FEF3C7">
<el-icon size="20" color="#F59E0B"><Shop /></el-icon>
</div>
<div class="menu-text">
<h4>健康商城</h4>
<p>选购适合您的保健品</p>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
<el-divider />
<div class="menu-item" @click="showAbout">
<div class="menu-icon" style="background: #F3F4F6">
<el-icon size="20" color="#6B7280"><InfoFilled /></el-icon>
</div>
<div class="menu-text">
<h4>关于我们</h4>
<p>了解健康AI助手</p>
</div>
<el-icon><ArrowRight /></el-icon>
</div>
</div>
</el-card>
<!-- 退出登录 -->
<el-button type="danger" plain class="logout-btn" @click="handleLogout">
退出登录
</el-button>
<p class="version">版本 1.0.0(原型版)</p>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Edit, Document, TrendCharts, ChatDotRound, Shop, InfoFilled, ArrowRight } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useConstitutionStore } from '@/stores/constitution'
import { constitutionNames } from '@/mock/constitution'
const router = useRouter()
const authStore = useAuthStore()
const constitutionStore = useConstitutionStore()
const handleEdit = () => {
ElMessage.info('编辑功能将在后续版本中提供')
}
const openMall = () => {
window.open('https://mall.example.com')
}
const showAbout = () => {
ElMessageBox.alert(
'健康AI助手 v1.0.0<br><br>结合中医体质辨识理论,为您提供个性化健康建议。',
'关于我们',
{ dangerouslyUseHTMLString: true }
)
}
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
authStore.logout()
router.push('/login')
})
}
</script>
<style scoped lang="scss">
.profile-page {
max-width: 600px;
margin: 0 auto;
}
.user-card {
margin-bottom: 20px;
.user-info {
display: flex;
align-items: center;
gap: 20px;
.el-avatar {
background: #10B981;
font-size: 28px;
}
.user-text {
flex: 1;
h2 {
font-size: 20px;
margin-bottom: 4px;
}
p {
color: #6B7280;
margin-bottom: 8px;
}
}
}
}
.menu-card {
margin-bottom: 20px;
.menu-list {
.menu-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
cursor: pointer;
&:hover {
background: #F9FAFB;
margin: 0 -20px;
padding: 12px 20px;
}
.menu-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.menu-text {
flex: 1;
h4 {
font-size: 15px;
margin-bottom: 2px;
}
p {
font-size: 13px;
color: #9CA3AF;
}
}
}
}
}
.logout-btn {
width: 100%;
margin-bottom: 16px;
}
.version {
text-align: center;
font-size: 12px;
color: #9CA3AF;
}
</style>
```
### 步骤 2:健康档案页面
创建 `src/views/profile/HealthRecordView.vue`
```vue
<template>
<div class="health-record-page">
<el-page-header @back="router.back()">
<template #content>健康档案</template>
</el-page-header>
<!-- 基础信息 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<el-icon><User /></el-icon>
<span>基础信息</span>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="姓名">{{ mockProfile.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ mockProfile.gender }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ mockProfile.age }}岁</el-descriptions-item>
<el-descriptions-item label="身高">{{ mockProfile.height }}cm</el-descriptions-item>
<el-descriptions-item label="体重">{{ mockProfile.weight }}kg</el-descriptions-item>
<el-descriptions-item label="BMI">{{ bmi }}</el-descriptions-item>
<el-descriptions-item label="血型">{{ mockProfile.bloodType }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 体质信息 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<el-icon><TrendCharts /></el-icon>
<span>体质信息</span>
</div>
</template>
<div v-if="constitutionStore.result" class="constitution-info">
<el-tag type="success" size="large">
{{ constitutionNames[constitutionStore.result.primaryType] }}
</el-tag>
<p class="constitution-desc">
{{ constitutionDescriptions[constitutionStore.result.primaryType].description }}
</p>
<p class="assessed-time">
测评时间:{{ formatTime(constitutionStore.result.assessedAt) }}
</p>
</div>
<el-empty v-else description="暂无体质测评记录">
<el-button type="primary" @click="router.push('/constitution')">
开始测评
</el-button>
</el-empty>
</el-card>
<!-- 既往病史 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<el-icon><FirstAidKit /></el-icon>
<span>既往病史</span>
</div>
</template>
<div class="tag-list">
<el-tag v-for="disease in mockProfile.medicalHistory" :key="disease">
{{ disease }}
</el-tag>
</div>
</el-card>
<!-- 过敏信息 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<el-icon color="#EF4444"><Warning /></el-icon>
<span>过敏信息</span>
</div>
</template>
<div class="tag-list">
<el-tag v-for="allergy in mockProfile.allergyRecords" :key="allergy" type="danger">
{{ allergy }}
</el-tag>
</div>
</el-card>
<!-- 生活习惯 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<el-icon><Clock /></el-icon>
<span>生活习惯</span>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="入睡时间">{{ mockProfile.sleepTime }}</el-descriptions-item>
<el-descriptions-item label="起床时间">{{ mockProfile.wakeTime }}</el-descriptions-item>
<el-descriptions-item label="运动频率">{{ mockProfile.exerciseFrequency }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-alert type="info" :closable="false" class="note-alert">
以上为模拟数据,后续将支持编辑和同步
</el-alert>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { User, TrendCharts, FirstAidKit, Warning, Clock } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import { useAuthStore } from '@/stores/auth'
import { useConstitutionStore } from '@/stores/constitution'
import { constitutionNames, constitutionDescriptions } from '@/mock/constitution'
const router = useRouter()
const authStore = useAuthStore()
const constitutionStore = useConstitutionStore()
// 模拟数据
const mockProfile = {
name: authStore.user?.nickname || '用户',
gender: '男',
age: 45,
height: 170,
weight: 68,
bloodType: 'A型',
medicalHistory: ['高血压', '轻度脂肪肝'],
allergyRecords: ['青霉素'],
sleepTime: '23:00',
wakeTime: '07:00',
exerciseFrequency: '每周2-3次'
}
const bmi = computed(() => {
const h = mockProfile.height / 100
return (mockProfile.weight / (h * h)).toFixed(1)
})
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
</script>
<style scoped lang="scss">
.health-record-page {
max-width: 800px;
margin: 0 auto;
}
.el-page-header {
margin-bottom: 20px;
}
.info-card {
margin-bottom: 20px;
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.constitution-info {
text-align: center;
padding: 20px;
.constitution-desc {
margin: 16px 0 8px;
color: #4B5563;
}
.assessed-time {
font-size: 12px;
color: #9CA3AF;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.note-alert {
margin-top: 20px;
}
</style>
```
---
## 验收标准
- [ ] 个人中心页面正常显示
- [ ] 菜单跳转正常
- [ ] 退出登录正常
- [ ] 健康档案页面正常显示
- [ ] 体质信息正确展示
---
## 预计耗时
30-35 分钟
---
## 完成
至此,Web 原型开发所有页面文档创建完成!
可以开始第四阶段:后端开发。

377
TODOS/04-APP开发/01-项目结构初始化.md

@ -0,0 +1,377 @@
# 01-APP React Native 项目结构初始化
## 目标
使用 React Native CLI 创建项目,配置基础依赖和目录结构。
---
## 前置要求
- React Native 环境已搭建
- Node.js 18+ 已安装
- Android Studio / Xcode 已配置
---
## 实施步骤
### 步骤 1:创建 React Native 项目
```bash
cd I:\apps\demo\healthApps
# 创建项目
npx react-native init app --template react-native-template-typescript
cd app
```
### 步骤 2:安装核心依赖
```bash
# 导航
npm install @react-navigation/native @react-navigation/native-stack @react-navigation/bottom-tabs
npm install react-native-screens react-native-safe-area-context
# 状态管理
npm install zustand
# 网络请求
npm install axios
# 图表(体质雷达图)
npm install react-native-svg
npm install react-native-gifted-charts
# 存储
npm install @react-native-async-storage/async-storage
# 表单
npm install react-hook-form
# UI 组件
npm install react-native-paper react-native-vector-icons
npm install @types/react-native-vector-icons -D
# 工具
npm install dayjs
```
### 步骤 3:配置 iOS 依赖(macOS)
```bash
cd ios
pod install
cd ..
```
### 步骤 4:创建目录结构
```bash
mkdir -p src/api
mkdir -p src/components/common
mkdir -p src/components/survey
mkdir -p src/components/constitution
mkdir -p src/components/chat
mkdir -p src/screens/auth
mkdir -p src/screens/survey
mkdir -p src/screens/constitution
mkdir -p src/screens/chat
mkdir -p src/screens/profile
mkdir -p src/navigation
mkdir -p src/stores
mkdir -p src/hooks
mkdir -p src/utils
mkdir -p src/types
mkdir -p src/assets
```
### 步骤 5:创建类型定义
创建 `src/types/index.ts`
```typescript
export interface User {
id: number
phone: string
email: string
nickname: string
avatar: string
survey_completed: boolean
}
export interface HealthProfile {
id: number
name: string
birth_date: string
gender: string
height: number
weight: number
bmi: number
blood_type: string
}
export interface Question {
id: number
constitution_type: string
question_text: string
options: string[]
order_num: number
}
export interface ConstitutionScore {
type: string
name: string
score: number
description: string
}
export interface ConstitutionResult {
primary_constitution: ConstitutionScore
secondary_constitutions: ConstitutionScore[]
all_scores: ConstitutionScore[]
recommendations: Record<string, Record<string, string>>
assessed_at: string
}
export interface Conversation {
id: number
title: string
created_at: string
updated_at: string
}
export interface Message {
id: number
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
```
### 步骤 6:创建 API 请求基础配置
创建 `src/api/request.ts`
```typescript
import axios from 'axios'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Alert } from 'react-native'
import type { ApiResponse } from '../types'
const API_BASE_URL = __DEV__
? 'http://10.0.2.2:8080/api' // Android 模拟器
: 'https://your-production-url.com/api'
const request = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
})
// 请求拦截器
request.interceptors.request.use(
async (config) => {
const token = await AsyncStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const res = response.data as ApiResponse
if (res.code !== 0) {
Alert.alert('提示', res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res.data
},
(error) => {
if (error.response?.status === 401) {
AsyncStorage.removeItem('token')
// 这里需要导航到登录页,后续在导航配置中处理
}
Alert.alert('错误', error.message || '网络错误')
return Promise.reject(error)
}
)
export default request
```
### 步骤 7:创建用户 Store
创建 `src/stores/userStore.ts`
```typescript
import { create } from 'zustand'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { User } from '../types'
interface UserState {
token: string
user: User | null
isLoggedIn: boolean
surveyCompleted: boolean
setToken: (token: string) => void
setUser: (user: User) => void
logout: () => void
loadToken: () => Promise<void>
}
export const useUserStore = create<UserState>((set, get) => ({
token: '',
user: null,
isLoggedIn: false,
surveyCompleted: false,
setToken: async (token: string) => {
await AsyncStorage.setItem('token', token)
set({ token, isLoggedIn: !!token })
},
setUser: (user: User) => {
set({ user, surveyCompleted: user.survey_completed })
},
logout: async () => {
await AsyncStorage.removeItem('token')
set({ token: '', user: null, isLoggedIn: false, surveyCompleted: false })
},
loadToken: async () => {
const token = await AsyncStorage.getItem('token')
if (token) {
set({ token, isLoggedIn: true })
}
},
}))
```
### 步骤 8:更新 App.tsx
更新 `App.tsx`
```typescript
import React, { useEffect, useState } from 'react'
import { StatusBar } from 'react-native'
import { NavigationContainer } from '@react-navigation/native'
import { Provider as PaperProvider } from 'react-native-paper'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useUserStore } from './src/stores/userStore'
import RootNavigator from './src/navigation/RootNavigator'
const App = () => {
const [isReady, setIsReady] = useState(false)
const loadToken = useUserStore((state) => state.loadToken)
useEffect(() => {
const init = async () => {
await loadToken()
setIsReady(true)
}
init()
}, [])
if (!isReady) {
return null // 或者显示 Splash Screen
}
return (
<SafeAreaProvider>
<PaperProvider>
<NavigationContainer>
<StatusBar barStyle="dark-content" />
<RootNavigator />
</NavigationContainer>
</PaperProvider>
</SafeAreaProvider>
)
}
export default App
```
### 步骤 9:验证项目
```bash
# Android
npm run android
# iOS (macOS)
npm run ios
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/types/index.ts` | 类型定义 |
| `src/api/request.ts` | 请求封装 |
| `src/stores/userStore.ts` | 用户状态管理 |
| `App.tsx` | 应用入口(更新) |
---
## 最终目录结构
```
app/
├── src/
│ ├── api/
│ │ └── request.ts
│ ├── components/
│ │ ├── common/
│ │ ├── survey/
│ │ ├── constitution/
│ │ └── chat/
│ ├── screens/
│ │ ├── auth/
│ │ ├── survey/
│ │ ├── constitution/
│ │ ├── chat/
│ │ └── profile/
│ ├── navigation/
│ ├── stores/
│ │ └── userStore.ts
│ ├── hooks/
│ ├── utils/
│ ├── types/
│ │ └── index.ts
│ └── assets/
├── App.tsx
├── package.json
├── android/
└── ios/
```
---
## 验收标准
- [ ] 项目创建成功
- [ ] 依赖安装完成
- [ ] 目录结构创建完整
- [ ] 模拟器启动正常
- [ ] 无报错
---
## 预计耗时
20-30 分钟
---
## 下一步
完成后进入 `04-APP开发/02-导航和布局设计.md`

448
TODOS/04-APP开发/02-导航和布局设计.md

@ -0,0 +1,448 @@
# 02-导航和布局设计
## 目标
配置 React Navigation 导航系统,实现 Tab 导航和 Stack 导航。
---
## UI 设计参考
> 参考设计稿:`files/ui/首页.png`
### 底部 Tab 导航规范
| Tab | 图标 | 文字 |
|-----|------|------|
| 首页 | 房屋图标 `home` | 首页 |
| AI问答 | 对话气泡 `chat-processing` | AI问答 |
| 体质分析 | 心电图 `chart-line-variant` | 体质分析 |
| 我的 | 用户图标 `account` | 我的 |
### Tab 样式规范
```typescript
const tabBarOptions = {
activeTintColor: '#10B981', // 选中颜色
inactiveTintColor: '#9CA3AF', // 未选中颜色
style: {
height: 60,
paddingBottom: 8,
paddingTop: 8,
backgroundColor: '#FFFFFF',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
},
labelStyle: {
fontSize: 12,
},
}
```
### 导航栏样式
```typescript
const headerOptions = {
headerStyle: {
backgroundColor: '#10B981',
},
headerTintColor: '#FFFFFF',
headerTitleStyle: {
fontWeight: '600',
},
}
```
---
## 前置要求
- 项目结构已初始化
- React Navigation 已安装
---
## 实施步骤
### 步骤 1:创建导航类型定义
创建 `src/navigation/types.ts`
```typescript
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'
import type { CompositeNavigationProp, RouteProp } from '@react-navigation/native'
// Root Stack
export type RootStackParamList = {
Auth: undefined
Main: undefined
Survey: undefined
}
// Auth Stack
export type AuthStackParamList = {
Login: undefined
Register: undefined
}
// Main Tab
export type MainTabParamList = {
ChatTab: undefined
ConstitutionTab: undefined
ProfileTab: undefined
}
// Chat Stack
export type ChatStackParamList = {
ChatList: undefined
ChatDetail: { id: number }
}
// Constitution Stack
export type ConstitutionStackParamList = {
ConstitutionHome: undefined
ConstitutionQuestions: undefined
ConstitutionResult: undefined
}
// Profile Stack
export type ProfileStackParamList = {
ProfileHome: undefined
HealthRecord: undefined
}
// Navigation Props
export type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>
export type AuthNavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AuthStackParamList>,
NativeStackNavigationProp<RootStackParamList>
>
export type MainTabNavigationProp = BottomTabNavigationProp<MainTabParamList>
export type ChatNavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<ChatStackParamList>,
MainTabNavigationProp
>
// Route Props
export type ChatDetailRouteProp = RouteProp<ChatStackParamList, 'ChatDetail'>
```
### 步骤 2:创建认证导航
创建 `src/navigation/AuthNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { AuthStackParamList } from './types'
import LoginScreen from '../screens/auth/LoginScreen'
import RegisterScreen from '../screens/auth/RegisterScreen'
const Stack = createNativeStackNavigator<AuthStackParamList>()
const AuthNavigator = () => {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
)
}
export default AuthNavigator
```
### 步骤 3:创建主 Tab 导航
创建 `src/navigation/MainTabNavigator.tsx`
```typescript
import React from 'react'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import type { MainTabParamList } from './types'
import ChatNavigator from './ChatNavigator'
import ConstitutionNavigator from './ConstitutionNavigator'
import ProfileNavigator from './ProfileNavigator'
const Tab = createBottomTabNavigator<MainTabParamList>()
const MainTabNavigator = () => {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#667eea',
tabBarInactiveTintColor: '#999',
tabBarStyle: {
height: 60,
paddingBottom: 8,
paddingTop: 8,
},
tabBarLabelStyle: {
fontSize: 12,
},
}}
>
<Tab.Screen
name="ChatTab"
component={ChatNavigator}
options={{
tabBarLabel: 'AI问诊',
tabBarIcon: ({ color, size }) => (
<Icon name="chat-processing" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="ConstitutionTab"
component={ConstitutionNavigator}
options={{
tabBarLabel: '体质测评',
tabBarIcon: ({ color, size }) => (
<Icon name="account-heart" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileNavigator}
options={{
tabBarLabel: '我的',
tabBarIcon: ({ color, size }) => (
<Icon name="account-circle" size={size} color={color} />
),
}}
/>
</Tab.Navigator>
)
}
export default MainTabNavigator
```
### 步骤 4:创建子导航器
创建 `src/navigation/ChatNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ChatStackParamList } from './types'
import ChatListScreen from '../screens/chat/ChatListScreen'
import ChatDetailScreen from '../screens/chat/ChatDetailScreen'
const Stack = createNativeStackNavigator<ChatStackParamList>()
const ChatNavigator = () => {
return (
<Stack.Navigator>
<Stack.Screen
name="ChatList"
component={ChatListScreen}
options={{ title: 'AI问诊' }}
/>
<Stack.Screen
name="ChatDetail"
component={ChatDetailScreen}
options={{ title: '对话' }}
/>
</Stack.Navigator>
)
}
export default ChatNavigator
```
创建 `src/navigation/ConstitutionNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ConstitutionStackParamList } from './types'
import ConstitutionHomeScreen from '../screens/constitution/ConstitutionHomeScreen'
import ConstitutionQuestionsScreen from '../screens/constitution/ConstitutionQuestionsScreen'
import ConstitutionResultScreen from '../screens/constitution/ConstitutionResultScreen'
const Stack = createNativeStackNavigator<ConstitutionStackParamList>()
const ConstitutionNavigator = () => {
return (
<Stack.Navigator>
<Stack.Screen
name="ConstitutionHome"
component={ConstitutionHomeScreen}
options={{ title: '体质测评' }}
/>
<Stack.Screen
name="ConstitutionQuestions"
component={ConstitutionQuestionsScreen}
options={{ title: '体质问卷' }}
/>
<Stack.Screen
name="ConstitutionResult"
component={ConstitutionResultScreen}
options={{ title: '测评结果' }}
/>
</Stack.Navigator>
)
}
export default ConstitutionNavigator
```
创建 `src/navigation/ProfileNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ProfileStackParamList } from './types'
import ProfileHomeScreen from '../screens/profile/ProfileHomeScreen'
import HealthRecordScreen from '../screens/profile/HealthRecordScreen'
const Stack = createNativeStackNavigator<ProfileStackParamList>()
const ProfileNavigator = () => {
return (
<Stack.Navigator>
<Stack.Screen
name="ProfileHome"
component={ProfileHomeScreen}
options={{ title: '我的' }}
/>
<Stack.Screen
name="HealthRecord"
component={HealthRecordScreen}
options={{ title: '健康档案' }}
/>
</Stack.Navigator>
)
}
export default ProfileNavigator
```
### 步骤 5:创建根导航器
创建 `src/navigation/RootNavigator.tsx`
```typescript
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { useUserStore } from '../stores/userStore'
import type { RootStackParamList } from './types'
import AuthNavigator from './AuthNavigator'
import MainTabNavigator from './MainTabNavigator'
import SurveyScreen from '../screens/survey/SurveyScreen'
const Stack = createNativeStackNavigator<RootStackParamList>()
const RootNavigator = () => {
const { isLoggedIn, surveyCompleted } = useUserStore()
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
{!isLoggedIn ? (
<Stack.Screen name="Auth" component={AuthNavigator} />
) : !surveyCompleted ? (
<Stack.Screen name="Survey" component={SurveyScreen} />
) : (
<Stack.Screen name="Main" component={MainTabNavigator} />
)}
</Stack.Navigator>
)
}
export default RootNavigator
```
### 步骤 6:创建占位页面
创建基础的占位页面组件,后续会详细实现。
创建 `src/screens/auth/LoginScreen.tsx`
```typescript
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
const LoginScreen = () => {
return (
<View style={styles.container}>
<Text>登录页面(待实现)</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
})
export default LoginScreen
```
(其他占位页面类似创建)
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/navigation/types.ts` | 导航类型定义 |
| `src/navigation/AuthNavigator.tsx` | 认证导航 |
| `src/navigation/MainTabNavigator.tsx` | Tab 导航 |
| `src/navigation/ChatNavigator.tsx` | 对话导航 |
| `src/navigation/ConstitutionNavigator.tsx` | 体质导航 |
| `src/navigation/ProfileNavigator.tsx` | 个人导航 |
| `src/navigation/RootNavigator.tsx` | 根导航 |
| 各占位页面 | 基础页面组件 |
---
## 导航结构
```
RootNavigator
├── AuthNavigator(未登录)
│ ├── Login
│ └── Register
├── Survey(未完成调查)
└── MainTabNavigator(已登录且完成调查)
├── ChatTab
│ ├── ChatList
│ └── ChatDetail
├── ConstitutionTab
│ ├── ConstitutionHome
│ ├── ConstitutionQuestions
│ └── ConstitutionResult
└── ProfileTab
├── ProfileHome
└── HealthRecord
```
---
## 验收标准
- [ ] 导航结构配置正确
- [ ] Tab 导航显示正常
- [ ] Stack 导航跳转正常
- [ ] 登录状态切换导航正常
- [ ] 类型定义完整
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `04-APP开发/03-用户认证页面.md`

535
TODOS/04-APP开发/03-用户认证页面.md

@ -0,0 +1,535 @@
# 03-用户认证页面
## 目标
实现 APP 端登录和注册页面。
---
## UI 设计参考
> 参考设计稿:`files/ui/登录页.png`
### 页面布局
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色渐变背景 (`#10B981 → #2EC4B6`) + 医疗插图 |
| Logo | "AI健康助手" 标题(白色 32px)+ slogan |
| 表单卡片 | 白色背景,圆角 16px |
| 输入框 | 圆角 12px,左侧带图标 |
| 主按钮 | 绿色 `#10B981`,圆角 24px,高度 48px |
### 样式常量
```typescript
const colors = {
primary: '#10B981',
primaryDark: '#059669',
background: '#10B981',
cardBackground: '#FFFFFF',
inputBackground: '#F3F4F6',
textPrimary: '#1F2937',
textSecondary: '#6B7280',
textHint: '#9CA3AF',
}
const spacing = {
screenPadding: 20,
cardPadding: 24,
inputMargin: 16,
}
const borderRadius = {
card: 16,
input: 12,
button: 24,
}
```
---
## 前置要求
- 导航配置完成
- 后端认证接口可用
---
## 实施步骤
### 步骤 1:创建认证 API
创建 `src/api/auth.ts`
```typescript
import request from './request'
export interface LoginRequest {
phone: string
password: string
}
export interface RegisterRequest {
phone: string
password: string
nickname?: string
}
export interface AuthResponse {
token: string
user_id: number
nickname: string
survey_completed: boolean
}
export const login = (data: LoginRequest): Promise<AuthResponse> => {
return request.post('/auth/login', data)
}
export const register = (data: RegisterRequest): Promise<AuthResponse> => {
return request.post('/auth/register', data)
}
```
### 步骤 2:创建登录页面
更新 `src/screens/auth/LoginScreen.tsx`
```typescript
import React, { useState } from 'react'
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native'
import { TextInput, Button } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { useUserStore } from '../../stores/userStore'
import { login } from '../../api/auth'
import type { AuthNavigationProp } from '../../navigation/types'
const LoginScreen = () => {
const navigation = useNavigation<AuthNavigationProp>()
const { setToken, setUser } = useUserStore()
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const handleLogin = async () => {
if (!phone.trim() || !password.trim()) {
return
}
setLoading(true)
try {
const res = await login({ phone, password })
setToken(res.token)
setUser({
id: res.user_id,
nickname: res.nickname,
phone,
email: '',
avatar: '',
survey_completed: res.survey_completed,
})
} catch (error) {
// 错误已在拦截器处理
} finally {
setLoading(false)
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.title}>健康AI助手</Text>
<Text style={styles.subtitle}>您的智能健康管家</Text>
</View>
<View style={styles.form}>
<TextInput
label="手机号"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
style={styles.input}
mode="outlined"
left={<TextInput.Icon icon="phone" />}
/>
<TextInput
label="密码"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
style={styles.input}
mode="outlined"
left={<TextInput.Icon icon="lock" />}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
/>
<Button
mode="contained"
onPress={handleLogin}
loading={loading}
disabled={loading || !phone.trim() || !password.trim()}
style={styles.button}
contentStyle={styles.buttonContent}
>
登录
</Button>
<View style={styles.footer}>
<Text style={styles.footerText}>还没有账号?</Text>
<Button
mode="text"
onPress={() => navigation.navigate('Register')}
compact
>
立即注册
</Button>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#667eea',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
padding: 20,
},
header: {
alignItems: 'center',
marginBottom: 40,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: 'rgba(255,255,255,0.8)',
},
form: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 24,
},
input: {
marginBottom: 16,
},
button: {
marginTop: 8,
borderRadius: 8,
},
buttonContent: {
paddingVertical: 8,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
},
footerText: {
color: '#666',
},
})
export default LoginScreen
```
### 步骤 3:创建注册页面
创建 `src/screens/auth/RegisterScreen.tsx`
```typescript
import React, { useState } from 'react'
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
Alert,
} from 'react-native'
import { TextInput, Button, Checkbox } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { useUserStore } from '../../stores/userStore'
import { register } from '../../api/auth'
import type { AuthNavigationProp } from '../../navigation/types'
const RegisterScreen = () => {
const navigation = useNavigation<AuthNavigationProp>()
const { setToken, setUser } = useUserStore()
const [phone, setPhone] = useState('')
const [nickname, setNickname] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [agreement, setAgreement] = useState(false)
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const handleRegister = async () => {
if (!phone.trim() || !password.trim()) {
Alert.alert('提示', '请输入手机号和密码')
return
}
if (password !== confirmPassword) {
Alert.alert('提示', '两次输入的密码不一致')
return
}
if (!agreement) {
Alert.alert('提示', '请先同意用户协议和隐私政策')
return
}
setLoading(true)
try {
const res = await register({
phone,
password,
nickname: nickname || undefined,
})
setToken(res.token)
setUser({
id: res.user_id,
nickname: res.nickname,
phone,
email: '',
avatar: '',
survey_completed: false,
})
} catch (error) {
// 错误已在拦截器处理
} finally {
setLoading(false)
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.title}>创建账号</Text>
</View>
<View style={styles.form}>
<TextInput
label="手机号"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
style={styles.input}
mode="outlined"
left={<TextInput.Icon icon="phone" />}
/>
<TextInput
label="昵称(选填)"
value={nickname}
onChangeText={setNickname}
style={styles.input}
mode="outlined"
left={<TextInput.Icon icon="account" />}
/>
<TextInput
label="密码"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
style={styles.input}
mode="outlined"
left={<TextInput.Icon icon="lock" />}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
/>
<TextInput
label="确认密码"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry={!showPassword}
style={styles.input}
mode="outlined"
left={<TextInput.Icon icon="lock-check" />}
/>
<View style={styles.checkboxRow}>
<Checkbox
status={agreement ? 'checked' : 'unchecked'}
onPress={() => setAgreement(!agreement)}
/>
<Text style={styles.agreementText}>
我已阅读并同意《用户协议》和《隐私政策》
</Text>
</View>
<Button
mode="contained"
onPress={handleRegister}
loading={loading}
disabled={loading || !phone.trim() || !password.trim() || !agreement}
style={styles.button}
contentStyle={styles.buttonContent}
>
注册
</Button>
<View style={styles.footer}>
<Text style={styles.footerText}>已有账号?</Text>
<Button
mode="text"
onPress={() => navigation.goBack()}
compact
>
立即登录
</Button>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#667eea',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
padding: 20,
},
header: {
alignItems: 'center',
marginBottom: 24,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
},
form: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 24,
},
input: {
marginBottom: 16,
},
checkboxRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
agreementText: {
flex: 1,
fontSize: 13,
color: '#666',
},
button: {
borderRadius: 8,
},
buttonContent: {
paddingVertical: 8,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
},
footerText: {
color: '#666',
},
})
export default RegisterScreen
```
---
## 需要创建的文件清单
| 文件路径 | 说明 |
|----------|------|
| `src/api/auth.ts` | 认证 API |
| `src/screens/auth/LoginScreen.tsx` | 登录页面 |
| `src/screens/auth/RegisterScreen.tsx` | 注册页面 |
---
## 验收标准
- [ ] 登录页面 UI 正常显示
- [ ] 注册页面 UI 正常显示
- [ ] 登录功能正常
- [ ] 注册功能正常
- [ ] 状态切换正确触发导航
---
## 预计耗时
25-30 分钟
---
## 下一步
完成后进入 `04-APP开发/04-健康调查页面.md`
---
## 备注
由于 APP 端实现与 Web 端逻辑类似,后续文档将提供精简版说明和关键代码,完整实现可参考 Web 端对应模块。
APP 端剩余页面(健康调查、体质测评、AI对话、个人中心)的详细实现将根据实际开发进度逐步补充。关键差异点:
1. **UI 框架**:使用 React Native Paper 代替 Element Plus
2. **导航**:使用 React Navigation 代替 Vue Router
3. **状态管理**:使用 Zustand 代替 Pinia
4. **样式**:使用 StyleSheet 代替 CSS
5. **表单**:使用 React Hook Form 或原生 useState

238
TODOS/04-APP开发/04-健康调查页面.md

@ -0,0 +1,238 @@
# 04-健康调查页面
## 目标
实现 APP 端新用户健康调查功能,包括多步骤表单。
---
## 实现要点
APP 端健康调查与 Web 端功能相同,主要差异:
1. **步骤指示器**:使用自定义组件或 `react-native-paper` 的 ProgressBar
2. **表单输入**:使用 `TextInput`、`RadioButton`、`Switch` 等 RN 组件
3. **日期选择**:使用 `@react-native-community/datetimepicker`
4. **滑动选择**:使用 `@react-native-community/slider`
---
## 关键代码示例
### 步骤指示器组件
```typescript
// src/components/common/StepIndicator.tsx
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
interface Props {
steps: string[]
currentStep: number
}
const StepIndicator: React.FC<Props> = ({ steps, currentStep }) => {
return (
<View style={styles.container}>
{steps.map((step, index) => (
<View key={index} style={styles.stepWrapper}>
<View
style={[
styles.circle,
index <= currentStep && styles.activeCircle,
]}
>
<Text style={[styles.number, index <= currentStep && styles.activeNumber]}>
{index + 1}
</Text>
</View>
<Text style={styles.label}>{step}</Text>
{index < steps.length - 1 && (
<View
style={[styles.line, index < currentStep && styles.activeLine]}
/>
)}
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
},
stepWrapper: {
alignItems: 'center',
flex: 1,
},
circle: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e0e0e0',
justifyContent: 'center',
alignItems: 'center',
},
activeCircle: {
backgroundColor: '#667eea',
},
number: {
color: '#999',
fontWeight: 'bold',
},
activeNumber: {
color: '#fff',
},
label: {
fontSize: 12,
color: '#666',
marginTop: 4,
},
line: {
position: 'absolute',
top: 16,
left: '60%',
right: '-40%',
height: 2,
backgroundColor: '#e0e0e0',
},
activeLine: {
backgroundColor: '#667eea',
},
})
export default StepIndicator
```
### 调查主页面
```typescript
// src/screens/survey/SurveyScreen.tsx
import React, { useState } from 'react'
import { View, ScrollView, StyleSheet, Alert } from 'react-native'
import { Button } from 'react-native-paper'
import StepIndicator from '../../components/common/StepIndicator'
import BasicInfoForm from '../../components/survey/BasicInfoForm'
import LifestyleForm from '../../components/survey/LifestyleForm'
import HealthStatusForm from '../../components/survey/HealthStatusForm'
import { useUserStore } from '../../stores/userStore'
const steps = ['基础信息', '生活习惯', '健康状况', '完成']
const SurveyScreen = () => {
const [currentStep, setCurrentStep] = useState(0)
const { setUser, user } = useUserStore()
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1)
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleComplete = () => {
if (user) {
setUser({ ...user, survey_completed: true })
}
}
return (
<View style={styles.container}>
<StepIndicator steps={steps} currentStep={currentStep} />
<ScrollView style={styles.content}>
{currentStep === 0 && <BasicInfoForm onNext={handleNext} />}
{currentStep === 1 && (
<LifestyleForm onPrev={handlePrev} onNext={handleNext} />
)}
{currentStep === 2 && (
<HealthStatusForm onPrev={handlePrev} onNext={handleNext} />
)}
{currentStep === 3 && (
<View style={styles.completeContainer}>
<Text style={styles.completeTitle}>健康调查完成!</Text>
<Text style={styles.completeSubtitle}>
接下来进行体质测评
</Text>
<Button mode="contained" onPress={handleComplete}>
开始体质测评
</Button>
</View>
)}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
content: {
flex: 1,
padding: 16,
},
completeContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 40,
},
completeTitle: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
},
completeSubtitle: {
fontSize: 16,
color: '#666',
marginBottom: 24,
},
})
export default SurveyScreen
```
---
## 需要创建的文件
| 文件路径 | 说明 |
|----------|------|
| `src/api/survey.ts` | 调查 API(同 Web 端) |
| `src/screens/survey/SurveyScreen.tsx` | 调查主页面 |
| `src/components/survey/BasicInfoForm.tsx` | 基础信息表单 |
| `src/components/survey/LifestyleForm.tsx` | 生活习惯表单 |
| `src/components/survey/HealthStatusForm.tsx` | 健康状况表单 |
| `src/components/common/StepIndicator.tsx` | 步骤指示器 |
---
## 验收标准
- [ ] 步骤指示器显示正确
- [ ] 各表单可正常填写
- [ ] 数据提交成功
- [ ] 完成后自动跳转
---
## 预计耗时
35-45 分钟
---
## 下一步
完成后进入 `04-APP开发/05-体质辨识页面.md`

471
TODOS/04-APP开发/05-体质辨识页面.md

@ -0,0 +1,471 @@
# 05-体质辨识页面
## 目标
实现 APP 端中医体质辨识问卷和结果展示功能。
---
## UI 设计参考
> 参考设计稿:`files/ui/体质页.png`、`files/ui/体质检测.png`、`files/ui/体质分析.png`
### 体质首页
| 区域 | 设计要点 |
|------|----------|
| 顶部卡片 | 绿色渐变背景 `#10B981 → #2EC4B6`,圆角 16px |
| 标题 | "中医体质自测",白色 20px 粗体 |
| 测试说明 | 白色卡片,3步骤(绿色序号圆圈) |
| 按钮 | 绿色全宽按钮,圆角 24px |
### 问卷页面
| 区域 | 设计要点 |
|------|----------|
| 进度显示 | 右上角 "1/65",绿色进度条高度 4px |
| 问题标签 | 绿色背景 `#DCFCE7`,文字 `#10B981` |
| 选项按钮 | 白色背景,边框 `#E5E7EB`,选中时绿色边框 + 背景 |
| 底部提示 | 浅绿色背景 `#ECFDF5`,info 图标 |
### 结果页面
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色渐变 + 人体轮廓 + 分享按钮 |
| 体质名称 | 白色大字 32px,分数徽章 |
| 雷达图 | 白色卡片,颜色 `#10B981`,透明填充 |
| 调理建议 | 2×2 网格,各带彩色图标背景 |
### 调理建议卡片
| 类型 | 图标背景 | 图标颜色 |
|------|----------|----------|
| 起居 | `#EDE9FE` | `#8B5CF6` |
| 饮食 | `#CCFBF1` | `#14B8A6` |
| 运动 | `#EDE9FE` | `#8B5CF6` |
| 情志 | `#FCE7F3` | `#EC4899` |
---
## 实现要点
1. **问卷 UI**:使用卡片+按钮组形式展示题目和选项
2. **进度条**:使用 `ProgressBar` 显示答题进度
3. **雷达图**:使用 `react-native-gifted-charts``victory-native`
4. **结果展示**:使用 Tab 切换调养建议
---
## 关键代码示例
### 问卷页面
```typescript
// src/screens/constitution/ConstitutionQuestionsScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, StyleSheet } from 'react-native'
import { Button, ProgressBar, Card } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { getQuestions, submitAssessment } from '../../api/constitution'
import type { Question } from '../../types'
const options = ['没有', '很少', '有时', '经常', '总是']
const ConstitutionQuestionsScreen = () => {
const navigation = useNavigation()
const [questions, setQuestions] = useState<Question[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<number, number>>({})
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
loadQuestions()
}, [])
const loadQuestions = async () => {
try {
const data = await getQuestions()
setQuestions(data)
} finally {
setLoading(false)
}
}
const currentQuestion = questions[currentIndex]
const progress = questions.length > 0 ? (currentIndex + 1) / questions.length : 0
const selectOption = (score: number) => {
setAnswers({ ...answers, [currentQuestion.id]: score })
}
const handleNext = () => {
if (currentIndex < questions.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const handleSubmit = async () => {
setSubmitting(true)
try {
const answerList = Object.entries(answers).map(([qid, score]) => ({
question_id: parseInt(qid),
score,
}))
await submitAssessment(answerList)
navigation.navigate('ConstitutionResult')
} finally {
setSubmitting(false)
}
}
if (loading || !currentQuestion) {
return (
<View style={styles.loadingContainer}>
<Text>加载中...</Text>
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.progressContainer}>
<Text style={styles.progressText}>
{currentIndex + 1} / {questions.length}
</Text>
<ProgressBar progress={progress} color="#667eea" />
</View>
<ScrollView style={styles.content}>
<Card style={styles.questionCard}>
<Card.Content>
<Text style={styles.questionText}>
{currentIndex + 1}. {currentQuestion.question_text}
</Text>
<View style={styles.options}>
{options.map((option, index) => (
<Button
key={index}
mode={answers[currentQuestion.id] === index + 1 ? 'contained' : 'outlined'}
onPress={() => selectOption(index + 1)}
style={styles.optionButton}
>
{option}
</Button>
))}
</View>
</Card.Content>
</Card>
</ScrollView>
<View style={styles.navButtons}>
<Button
mode="outlined"
onPress={handlePrev}
disabled={currentIndex === 0}
>
上一题
</Button>
{currentIndex < questions.length - 1 ? (
<Button
mode="contained"
onPress={handleNext}
disabled={!answers[currentQuestion.id]}
>
下一题
</Button>
) : (
<Button
mode="contained"
onPress={handleSubmit}
loading={submitting}
disabled={Object.keys(answers).length < questions.length}
>
提交
</Button>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
progressContainer: {
padding: 16,
backgroundColor: '#fff',
},
progressText: {
textAlign: 'center',
marginBottom: 8,
color: '#666',
},
content: {
flex: 1,
padding: 16,
},
questionCard: {
marginBottom: 16,
},
questionText: {
fontSize: 18,
lineHeight: 28,
marginBottom: 20,
},
options: {
gap: 12,
},
optionButton: {
marginBottom: 8,
},
navButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
padding: 16,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#eee',
},
})
export default ConstitutionQuestionsScreen
```
### 结果页面(使用 victory-native 雷达图)
```typescript
// src/screens/constitution/ConstitutionResultScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, StyleSheet } from 'react-native'
import { Card, Chip, Button } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { getLatestResult } from '../../api/constitution'
import type { ConstitutionResult } from '../../types'
const ConstitutionResultScreen = () => {
const navigation = useNavigation()
const [result, setResult] = useState<ConstitutionResult | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadResult()
}, [])
const loadResult = async () => {
try {
const data = await getLatestResult()
setResult(data)
} finally {
setLoading(false)
}
}
if (loading || !result) {
return (
<View style={styles.loadingContainer}>
<Text>加载中...</Text>
</View>
)
}
return (
<ScrollView style={styles.container}>
{/* 主要体质 */}
<Card style={styles.primaryCard}>
<Card.Content>
<Text style={styles.sectionTitle}>您的体质类型</Text>
<Chip style={styles.primaryChip} textStyle={styles.primaryChipText}>
{result.primary_constitution.name}
</Chip>
<Text style={styles.description}>
{result.primary_constitution.description}
</Text>
</Card.Content>
</Card>
{/* 所有体质得分 */}
<Card style={styles.card}>
<Card.Content>
<Text style={styles.sectionTitle}>体质得分</Text>
{result.all_scores.map((score) => (
<View key={score.type} style={styles.scoreItem}>
<Text style={styles.scoreName}>{score.name}</Text>
<View style={styles.scoreBar}>
<View
style={[styles.scoreBarFill, { width: `${score.score}%` }]}
/>
</View>
<Text style={styles.scoreValue}>{score.score.toFixed(0)}</Text>
</View>
))}
</Card.Content>
</Card>
{/* 调养建议 */}
<Card style={styles.card}>
<Card.Content>
<Text style={styles.sectionTitle}>调养建议</Text>
{Object.entries(result.recommendations).map(([type, recs]) => (
<View key={type} style={styles.recSection}>
{Object.entries(recs).map(([key, value]) => (
<View key={key} style={styles.recItem}>
<Text style={styles.recTitle}>
{key === 'diet' ? '饮食' : key === 'lifestyle' ? '起居' : key === 'exercise' ? '运动' : '情志'}
</Text>
<Text style={styles.recText}>{value}</Text>
</View>
))}
</View>
))}
</Card.Content>
</Card>
<View style={styles.actions}>
<Button
mode="contained"
onPress={() => navigation.navigate('ChatTab')}
>
开始 AI 问诊
</Button>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 16,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
primaryCard: {
marginBottom: 16,
alignItems: 'center',
},
card: {
marginBottom: 16,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 12,
},
primaryChip: {
backgroundColor: '#667eea',
alignSelf: 'center',
marginBottom: 12,
},
primaryChipText: {
color: '#fff',
fontSize: 16,
},
description: {
textAlign: 'center',
color: '#666',
lineHeight: 22,
},
scoreItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
scoreName: {
width: 60,
fontSize: 13,
},
scoreBar: {
flex: 1,
height: 8,
backgroundColor: '#e0e0e0',
borderRadius: 4,
marginHorizontal: 8,
},
scoreBarFill: {
height: '100%',
backgroundColor: '#667eea',
borderRadius: 4,
},
scoreValue: {
width: 30,
textAlign: 'right',
fontSize: 13,
},
recSection: {
marginBottom: 16,
},
recItem: {
backgroundColor: '#f9f9f9',
padding: 12,
borderRadius: 8,
marginBottom: 8,
},
recTitle: {
fontWeight: 'bold',
marginBottom: 4,
},
recText: {
color: '#666',
lineHeight: 20,
},
actions: {
padding: 16,
alignItems: 'center',
},
})
export default ConstitutionResultScreen
```
---
## 需要创建的文件
| 文件路径 | 说明 |
|----------|------|
| `src/api/constitution.ts` | 体质 API |
| `src/screens/constitution/ConstitutionHomeScreen.tsx` | 测评首页 |
| `src/screens/constitution/ConstitutionQuestionsScreen.tsx` | 问卷页面 |
| `src/screens/constitution/ConstitutionResultScreen.tsx` | 结果页面 |
---
## 验收标准
- [ ] 问卷正常加载和答题
- [ ] 进度条显示正确
- [ ] 提交后显示结果
- [ ] 调养建议完整显示
---
## 预计耗时
35-45 分钟
---
## 下一步
完成后进入 `04-APP开发/06-AI对话页面.md`

476
TODOS/04-APP开发/06-AI对话页面.md

@ -0,0 +1,476 @@
# 06-AI对话页面
## 目标
实现 APP 端 AI 健康问诊对话功能。
---
## UI 设计参考
> 参考设计稿:`files/ui/问答页.png`、`files/ui/问答对话.png`
### 对话首页
| 区域 | 设计要点 |
|------|----------|
| 导航栏 | "AI健康助手" + 绿色 "在线" 状态 |
| AI 欢迎语 | 机器人图标(蓝色 `#3B82F6`)+ 灰色气泡 |
| 常见问题 | 灰色标签 + 白色圆角按钮(多个快捷问题) |
| 输入区 | 麦克风 + 输入框 + 绿色发送按钮 |
### 消息气泡
| 类型 | 样式 |
|------|------|
| AI 消息 | 左对齐,机器人图标蓝色 `#3B82F6`,气泡白色 `#FFFFFF` |
| 用户消息 | 右对齐,用户图标绿色 `#10B981`,气泡绿色 `#10B981`,文字白色 |
| 时间 | 灰色 `#9CA3AF`,位于气泡下方 |
### 样式常量
```typescript
const chatStyles = {
// 气泡
userBubble: {
backgroundColor: '#10B981',
borderRadius: 16,
borderBottomRightRadius: 4,
},
assistantBubble: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
borderBottomLeftRadius: 4,
},
// 头像
userAvatar: {
backgroundColor: '#10B981',
},
aiAvatar: {
backgroundColor: '#3B82F6',
},
// 输入区
inputContainer: {
backgroundColor: '#FFFFFF',
borderTopColor: '#E5E7EB',
},
sendButton: {
backgroundColor: '#10B981',
borderRadius: 20,
},
}
```
---
## 实现要点
1. **消息列表**:使用 FlatList 渲染消息,支持自动滚动到底部
2. **输入框**:固定在底部,支持多行输入
3. **键盘适配**:使用 KeyboardAvoidingView 处理键盘弹出
4. **消息气泡**:区分用户和 AI 消息样式
---
## 关键代码示例
### 对话列表页面
```typescript
// src/screens/chat/ChatListScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, FlatList, StyleSheet, TouchableOpacity } from 'react-native'
import { Text, FAB, Card, IconButton } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import dayjs from 'dayjs'
import { getConversations, createConversation, deleteConversation } from '../../api/conversation'
import type { Conversation } from '../../types'
import type { ChatNavigationProp } from '../../navigation/types'
const ChatListScreen = () => {
const navigation = useNavigation<ChatNavigationProp>()
const [conversations, setConversations] = useState<Conversation[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadConversations()
}, [])
const loadConversations = async () => {
try {
const data = await getConversations()
setConversations(data)
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
const conv = await createConversation()
navigation.navigate('ChatDetail', { id: conv.id })
}
const handleDelete = async (id: number) => {
await deleteConversation(id)
setConversations(conversations.filter((c) => c.id !== id))
}
const renderItem = ({ item }: { item: Conversation }) => (
<TouchableOpacity
onPress={() => navigation.navigate('ChatDetail', { id: item.id })}
>
<Card style={styles.card}>
<Card.Content style={styles.cardContent}>
<View style={styles.cardInfo}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardTime}>
{dayjs(item.updated_at).format('MM-DD HH:mm')}
</Text>
</View>
<IconButton
icon="delete"
size={20}
onPress={() => handleDelete(item.id)}
/>
</Card.Content>
</Card>
</TouchableOpacity>
)
return (
<View style={styles.container}>
{conversations.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>暂无对话记录</Text>
<Text style={styles.emptySubtext}>点击下方按钮开始第一次对话</Text>
</View>
) : (
<FlatList
data={conversations}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.list}
/>
)}
<FAB
icon="plus"
style={styles.fab}
onPress={handleCreate}
label="新建对话"
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
list: {
padding: 16,
},
card: {
marginBottom: 12,
},
cardContent: {
flexDirection: 'row',
alignItems: 'center',
},
cardInfo: {
flex: 1,
},
cardTitle: {
fontSize: 16,
fontWeight: '500',
},
cardTime: {
fontSize: 12,
color: '#999',
marginTop: 4,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
fontSize: 16,
color: '#666',
},
emptySubtext: {
fontSize: 14,
color: '#999',
marginTop: 8,
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
},
})
export default ChatListScreen
```
### 对话详情页面
```typescript
// src/screens/chat/ChatDetailScreen.tsx
import React, { useState, useEffect, useRef } from 'react'
import {
View,
FlatList,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native'
import { Text, TextInput, IconButton, Avatar } from 'react-native-paper'
import { useRoute } from '@react-navigation/native'
import dayjs from 'dayjs'
import { getConversation, sendMessage } from '../../api/conversation'
import { useUserStore } from '../../stores/userStore'
import type { Message } from '../../types'
import type { ChatDetailRouteProp } from '../../navigation/types'
const ChatDetailScreen = () => {
const route = useRoute<ChatDetailRouteProp>()
const { id } = route.params
const { user } = useUserStore()
const flatListRef = useRef<FlatList>(null)
const [messages, setMessages] = useState<Message[]>([])
const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadMessages()
}, [])
const loadMessages = async () => {
try {
const data = await getConversation(id)
setMessages(data.messages || [])
} finally {
setLoading(false)
}
}
const handleSend = async () => {
const content = inputText.trim()
if (!content || sending) return
// 添加用户消息
const userMessage: Message = {
id: Date.now(),
role: 'user',
content,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMessage])
setInputText('')
setSending(true)
try {
const res = await sendMessage(id, content)
const assistantMessage: Message = {
id: Date.now() + 1,
role: 'assistant',
content: res.reply,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, assistantMessage])
} catch (error) {
// 移除用户消息
setMessages((prev) => prev.slice(0, -1))
setInputText(content)
} finally {
setSending(false)
}
}
const renderMessage = ({ item }: { item: Message }) => {
const isUser = item.role === 'user'
return (
<View style={[styles.messageRow, isUser && styles.messageRowUser]}>
{!isUser && (
<Avatar.Text size={36} label="AI" style={styles.avatar} />
)}
<View
style={[
styles.messageBubble,
isUser ? styles.userBubble : styles.assistantBubble,
]}
>
<Text style={isUser ? styles.userText : styles.assistantText}>
{item.content}
</Text>
</View>
{isUser && (
<Avatar.Text
size={36}
label={user?.nickname?.charAt(0) || 'U'}
style={styles.avatar}
/>
)}
</View>
)
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.messageList}
onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
/>
{sending && (
<View style={styles.typingIndicator}>
<Text style={styles.typingText}>AI 正在输入...</Text>
</View>
)}
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="请描述您的健康问题..."
multiline
disabled={sending}
/>
<IconButton
icon="send"
size={24}
disabled={!inputText.trim() || sending}
onPress={handleSend}
/>
</View>
<View style={styles.disclaimer}>
<Text style={styles.disclaimerText}>
AI 建议仅供参考,不构成医疗诊断
</Text>
</View>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
messageList: {
padding: 16,
},
messageRow: {
flexDirection: 'row',
marginBottom: 16,
alignItems: 'flex-end',
},
messageRowUser: {
flexDirection: 'row-reverse',
},
avatar: {
marginHorizontal: 8,
},
messageBubble: {
maxWidth: '70%',
padding: 12,
borderRadius: 16,
},
userBubble: {
backgroundColor: '#667eea',
borderBottomRightRadius: 4,
},
assistantBubble: {
backgroundColor: '#fff',
borderBottomLeftRadius: 4,
},
userText: {
color: '#fff',
},
assistantText: {
color: '#333',
},
typingIndicator: {
paddingHorizontal: 16,
paddingVertical: 8,
},
typingText: {
color: '#999',
fontSize: 13,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#eee',
},
input: {
flex: 1,
maxHeight: 100,
backgroundColor: '#f5f5f5',
borderRadius: 20,
paddingHorizontal: 16,
},
disclaimer: {
padding: 8,
backgroundColor: '#fef0f0',
alignItems: 'center',
},
disclaimerText: {
fontSize: 12,
color: '#f56c6c',
},
})
export default ChatDetailScreen
```
---
## 需要创建的文件
| 文件路径 | 说明 |
|----------|------|
| `src/api/conversation.ts` | 对话 API |
| `src/screens/chat/ChatListScreen.tsx` | 对话列表 |
| `src/screens/chat/ChatDetailScreen.tsx` | 对话详情 |
---
## 验收标准
- [ ] 对话列表正常显示
- [ ] 新建和删除对话正常
- [ ] 消息发送和接收正常
- [ ] 键盘弹出时布局正常
- [ ] 免责声明显示
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `04-APP开发/07-个人中心页面.md`

494
TODOS/04-APP开发/07-个人中心页面.md

@ -0,0 +1,494 @@
# 07-个人中心页面
## 目标
实现 APP 端个人中心和健康档案管理页面。
---
## UI 设计参考
> 参考设计稿:`files/ui/我的.png`
### 页面布局
| 区域 | 设计要点 |
|------|----------|
| 顶部 | 绿色背景 `#10B981` + "我的" 标题(白色) |
| 用户卡片 | 头像(64px 圆形)+ 姓名 + 基本信息 + 用户ID |
| 编辑按钮 | 白色半透明背景,编辑图标 |
| 健康管理 | "用药情况" 入口(带角标 "12条") |
| 设置列表 | 消息通知、隐私设置、通用设置 |
### 用户卡片样式
```typescript
const userCardStyles = {
container: {
backgroundColor: '#10B981',
padding: 20,
borderRadius: 16,
},
avatar: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(255,255,255,0.2)',
},
nickname: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '600',
},
basicInfo: {
color: '#FFFFFF',
fontSize: 14,
},
userId: {
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
},
}
```
### 列表项样式
| 元素 | 样式 |
|------|------|
| 图标背景 | 40px 圆形 |
| 用药情况 | `#DCFCE7` 背景,`#10B981` 图标 |
| 消息通知 | `#DCFCE7` 背景,铃铛图标 |
| 隐私设置 | `#DCFCE7` 背景,盾牌图标 |
| 通用设置 | `#DCFCE7` 背景,齿轮图标 |
| 角标 | 灰色文字 `#6B7280` |
| 右箭头 | 灰色 `#9CA3AF` |
```typescript
const listItemStyles = {
container: {
backgroundColor: '#FFFFFF',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#DCFCE7',
justifyContent: 'center',
alignItems: 'center',
},
iconColor: '#10B981',
title: {
fontSize: 16,
color: '#1F2937',
},
description: {
fontSize: 13,
color: '#6B7280',
},
badge: {
fontSize: 13,
color: '#6B7280',
},
}
```
---
## 实现要点
1. **用户信息卡片**:显示头像、昵称、手机号
2. **功能菜单列表**:使用 List 组件展示菜单项
3. **健康档案**:展示基础信息、体质、病史等
---
## 关键代码示例
### 个人中心页面
```typescript
// src/screens/profile/ProfileHomeScreen.tsx
import React from 'react'
import { View, ScrollView, StyleSheet, Alert } from 'react-native'
import { Text, Avatar, Card, List, Button, Divider } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { useUserStore } from '../../stores/userStore'
import type { ProfileStackParamList } from '../../navigation/types'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
type NavigationProp = NativeStackNavigationProp<ProfileStackParamList>
const ProfileHomeScreen = () => {
const navigation = useNavigation<NavigationProp>()
const { user, logout } = useUserStore()
const handleLogout = () => {
Alert.alert('提示', '确定要退出登录吗?', [
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: () => logout(),
},
])
}
return (
<ScrollView style={styles.container}>
{/* 用户信息卡片 */}
<Card style={styles.userCard}>
<Card.Content style={styles.userContent}>
<Avatar.Text
size={64}
label={user?.nickname?.charAt(0) || 'U'}
style={styles.avatar}
/>
<View style={styles.userInfo}>
<Text style={styles.nickname}>{user?.nickname || '用户'}</Text>
<Text style={styles.phone}>{user?.phone}</Text>
</View>
</Card.Content>
</Card>
{/* 功能菜单 */}
<Card style={styles.menuCard}>
<List.Item
title="健康档案"
description="查看和管理您的健康信息"
left={(props) => <List.Icon {...props} icon="file-document" />}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => navigation.navigate('HealthRecord')}
/>
<Divider />
<List.Item
title="体质报告"
description="查看您的体质辨识结果"
left={(props) => <List.Icon {...props} icon="chart-line" />}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => navigation.getParent()?.navigate('ConstitutionTab')}
/>
<Divider />
<List.Item
title="重新测评"
description="建议每3-6个月重新测评一次"
left={(props) => <List.Icon {...props} icon="refresh" />}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => navigation.getParent()?.navigate('ConstitutionTab', {
screen: 'ConstitutionQuestions',
})}
/>
<Divider />
<List.Item
title="关于我们"
description="了解健康AI助手"
left={(props) => <List.Icon {...props} icon="information" />}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() =>
Alert.alert(
'关于我们',
'健康AI助手是一款智能健康咨询应用,结合中医体质辨识理论,为您提供个性化的健康建议。\n\n版本:1.0.0'
)
}
/>
</Card>
{/* 退出登录 */}
<View style={styles.logoutContainer}>
<Button mode="text" textColor="#f56c6c" onPress={handleLogout}>
退出登录
</Button>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
userCard: {
margin: 16,
},
userContent: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
backgroundColor: '#667eea',
},
userInfo: {
marginLeft: 16,
},
nickname: {
fontSize: 20,
fontWeight: 'bold',
},
phone: {
color: '#999',
marginTop: 4,
},
menuCard: {
marginHorizontal: 16,
},
logoutContainer: {
padding: 24,
alignItems: 'center',
},
})
export default ProfileHomeScreen
```
### 健康档案页面
```typescript
// src/screens/profile/HealthRecordScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { Text, Card, Chip, ActivityIndicator } from 'react-native-paper'
import { getHealthProfile } from '../../api/user'
const genderMap: Record<string, string> = {
male: '男',
female: '女',
}
const HealthRecordScreen = () => {
const [profile, setProfile] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadProfile()
}, [])
const loadProfile = async () => {
try {
const data = await getHealthProfile()
setProfile(data)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
</View>
)
}
return (
<ScrollView style={styles.container}>
{/* 基础信息 */}
<Card style={styles.card}>
<Card.Title title="基础信息" />
<Card.Content>
{profile?.basic_info ? (
<View style={styles.infoGrid}>
<InfoItem label="姓名" value={profile.basic_info.name} />
<InfoItem
label="性别"
value={genderMap[profile.basic_info.gender]}
/>
<InfoItem
label="身高"
value={profile.basic_info.height ? `${profile.basic_info.height} cm` : '-'}
/>
<InfoItem
label="体重"
value={profile.basic_info.weight ? `${profile.basic_info.weight} kg` : '-'}
/>
<InfoItem
label="BMI"
value={profile.basic_info.bmi?.toFixed(1)}
/>
<InfoItem label="血型" value={profile.basic_info.blood_type} />
</View>
) : (
<Text style={styles.emptyText}>暂无基础信息</Text>
)}
</Card.Content>
</Card>
{/* 体质信息 */}
<Card style={styles.card}>
<Card.Title title="体质信息" />
<Card.Content>
{profile?.constitution ? (
<View style={styles.constitutionInfo}>
<Chip style={styles.constitutionChip}>
{profile.constitution.primary_name}
</Chip>
<Text style={styles.constitutionDesc}>
{profile.constitution.primary_description}
</Text>
<Text style={styles.assessedTime}>
测评时间:{profile.constitution.assessed_at}
</Text>
</View>
) : (
<Text style={styles.emptyText}>暂无体质测评记录</Text>
)}
</Card.Content>
</Card>
{/* 既往病史 */}
<Card style={styles.card}>
<Card.Title title="既往病史" />
<Card.Content>
{profile?.medical_history?.length > 0 ? (
<View style={styles.tagList}>
{profile.medical_history.map((item: any) => (
<Chip key={item.id} style={styles.tag}>
{item.disease_name}
</Chip>
))}
</View>
) : (
<Text style={styles.emptyText}>暂无病史记录</Text>
)}
</Card.Content>
</Card>
{/* 过敏信息 */}
<Card style={styles.card}>
<Card.Title title="过敏信息" />
<Card.Content>
{profile?.allergy_records?.length > 0 ? (
<View style={styles.tagList}>
{profile.allergy_records.map((item: any) => (
<Chip key={item.id} style={[styles.tag, styles.allergyTag]}>
{item.allergen}
</Chip>
))}
</View>
) : (
<Text style={styles.emptyText}>暂无过敏信息</Text>
)}
</Card.Content>
</Card>
</ScrollView>
)
}
const InfoItem = ({ label, value }: { label: string; value?: string }) => (
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value || '-'}</Text>
</View>
)
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 16,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
card: {
marginBottom: 16,
},
infoGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
infoItem: {
width: '50%',
marginBottom: 12,
},
infoLabel: {
fontSize: 13,
color: '#999',
marginBottom: 4,
},
infoValue: {
fontSize: 15,
},
emptyText: {
color: '#999',
textAlign: 'center',
padding: 16,
},
constitutionInfo: {
alignItems: 'center',
},
constitutionChip: {
backgroundColor: '#667eea',
marginBottom: 12,
},
constitutionDesc: {
color: '#666',
textAlign: 'center',
lineHeight: 22,
},
assessedTime: {
marginTop: 12,
fontSize: 12,
color: '#999',
},
tagList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
tag: {
marginBottom: 8,
},
allergyTag: {
backgroundColor: '#fef0f0',
},
})
export default HealthRecordScreen
```
---
## 需要创建的文件
| 文件路径 | 说明 |
|----------|------|
| `src/api/user.ts` | 用户 API |
| `src/screens/profile/ProfileHomeScreen.tsx` | 个人中心 |
| `src/screens/profile/HealthRecordScreen.tsx` | 健康档案 |
---
## 验收标准
- [ ] 用户信息正确显示
- [ ] 菜单导航正常
- [ ] 健康档案数据完整
- [ ] 退出登录功能正常
---
## 预计耗时
25-30 分钟
---
## 完成
恭喜!APP 端开发任务全部完成!
---
## 后续工作
1. **测试**:在真机和模拟器上进行完整功能测试
2. **优化**:性能优化、动画效果、错误处理
3. **打包**
- Android: `cd android && ./gradlew assembleRelease`
- iOS: 使用 Xcode Archive
4. **发布**:提交到应用商店审核

185
TODOS/04-后端开发/01-项目结构初始化.md

@ -0,0 +1,185 @@
# 01-后端项目结构初始化
## 目标
创建 Go + Gin 后端项目的基础目录结构和配置文件。
---
## 前置要求
- Go 1.21+ 已安装
- 环境变量已配置
---
## 实施步骤
### 步骤 1:创建项目目录
```bash
cd I:\apps\demo\healthApps
mkdir -p server
cd server
```
### 步骤 2:初始化 Go 模块
```bash
go mod init health-ai
```
### 步骤 3:创建目录结构
```bash
mkdir -p cmd/server
mkdir -p internal/api/handler
mkdir -p internal/api/middleware
mkdir -p internal/model
mkdir -p internal/service
mkdir -p internal/repository/impl
mkdir -p internal/config
mkdir -p internal/database
mkdir -p pkg/jwt
mkdir -p pkg/response
mkdir -p pkg/utils
mkdir -p data
```
### 步骤 4:安装核心依赖
```bash
# Web 框架
go get -u github.com/gin-gonic/gin
# ORM
go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite
# 配置管理
go get -u github.com/spf13/viper
# 日志
go get -u go.uber.org/zap
# JWT
go get -u github.com/golang-jwt/jwt/v5
# 密码加密
go get -u golang.org/x/crypto/bcrypt
# 参数验证
go get -u github.com/go-playground/validator/v10
# 跨域
go get -u github.com/gin-contrib/cors
```
### 步骤 5:创建配置文件
创建 `server/config.yaml`
```yaml
server:
port: 8080
mode: debug # debug, release, test
database:
driver: sqlite # sqlite, postgres, mysql
sqlite:
path: ./data/health.db
postgres:
host: localhost
port: 5432
user: postgres
password: ""
dbname: health_app
mysql:
host: localhost
port: 3306
user: root
password: ""
dbname: health_app
jwt:
secret: your-secret-key-change-in-production
expire_hours: 24
ai:
provider: openai # openai, qwen
api_key: ""
base_url: ""
```
### 步骤 6:创建入口文件
创建 `server/cmd/server/main.go`
```go
package main
import (
"log"
)
func main() {
log.Println("Health AI Server Starting...")
// TODO: 初始化配置、数据库、路由
}
```
### 步骤 7:验证项目
```bash
cd server
go mod tidy
go run cmd/server/main.go
# 输出: Health AI Server Starting...
```
---
## 最终目录结构
```
server/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── api/
│ │ ├── handler/
│ │ └── middleware/
│ ├── model/
│ ├── service/
│ ├── repository/
│ │ └── impl/
│ ├── config/
│ └── database/
├── pkg/
│ ├── jwt/
│ ├── response/
│ └── utils/
├── data/
├── config.yaml
├── go.mod
└── go.sum
```
---
## 验收标准
- [ ] 目录结构创建完成
- [ ] `go mod tidy` 无报错
- [ ] `go run cmd/server/main.go` 正常输出
---
## 预计耗时
10-15 分钟
---
## 下一步
完成后进入 `04-后端开发/02-数据库和模型设计.md`

51
TODOS/04-后端开发/02-数据库和模型设计.md

@ -0,0 +1,51 @@
# 02-数据库和模型设计
## 目标
实现数据库连接模块和所有数据模型定义,支持多数据库切换。
---
## 前置要求
- 项目结构已初始化
- 依赖已安装
---
## 实施步骤
详细代码请参考原文档 `02-后端开发/02-数据库和模型设计.md`
### 主要任务
1. 创建配置加载模块 `internal/config/config.go`
2. 创建数据库连接模块 `internal/database/database.go`
3. 创建数据模型:
- `internal/model/user.go` - 用户、健康档案、生活习惯
- `internal/model/health.go` - 病史、家族史、过敏记录
- `internal/model/constitution.go` - 体质测评
- `internal/model/conversation.go` - 对话消息
4. 创建模型聚合文件 `internal/model/models.go`
5. 更新主程序初始化数据库
---
## 验收标准
- [ ] 配置文件可正常加载
- [ ] SQLite 数据库文件自动创建
- [ ] 所有表自动迁移成功
- [ ] `data/health.db` 文件生成
---
## 预计耗时
20-30 分钟
---
## 下一步
完成后进入 `04-后端开发/03-用户认证模块.md`

59
TODOS/04-后端开发/03-用户认证模块.md

@ -0,0 +1,59 @@
# 03-用户认证模块
## 目标
实现用户注册、登录、Token 刷新等认证功能。
---
## 前置要求
- 数据库和模型已完成
- JWT 依赖已安装
---
## 实施步骤
详细代码请参考原文档 `02-后端开发/03-用户认证模块.md`
### 主要任务
1. 创建统一响应工具 `pkg/response/response.go`
2. 创建 JWT 工具 `pkg/jwt/jwt.go`
3. 创建认证中间件 `internal/api/middleware/auth.go`
4. 创建用户 Repository `internal/repository/impl/user.go`
5. 创建认证 Service `internal/service/auth.go`
6. 创建认证 Handler `internal/api/handler/auth.go`
7. 创建路由配置 `internal/api/router.go`
8. 更新主程序启动服务
---
## API 接口
| 方法 | 路径 | 说明 |
|-----|------|------|
| POST | /api/auth/register | 用户注册 |
| POST | /api/auth/login | 用户登录 |
---
## 验收标准
- [ ] 服务启动成功,监听 8080 端口
- [ ] `/health` 返回 `{"status": "ok"}`
- [ ] 注册接口正常创建用户
- [ ] 登录接口返回有效 Token
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `04-后端开发/04-健康调查模块.md`

60
TODOS/04-后端开发/04-健康调查模块.md

@ -0,0 +1,60 @@
# 04-健康调查模块
## 目标
实现新用户健康调查功能,包括基础信息、生活习惯、病史、过敏史等信息的提交和管理。
---
## 前置要求
- 用户认证模块已完成
- 数据模型已定义
---
## 实施步骤
详细代码请参考原文档 `02-后端开发/04-健康调查模块.md`
### 主要任务
1. 创建健康档案 Repository `internal/repository/impl/health.go`
2. 创建健康调查 Service `internal/service/survey.go`
3. 创建健康调查 Handler `internal/api/handler/survey.go`
4. 更新路由配置
---
## API 接口
| 方法 | 路径 | 说明 |
|-----|------|------|
| GET | /api/survey/status | 获取调查完成状态 |
| POST | /api/survey/basic-info | 提交基础信息 |
| POST | /api/survey/lifestyle | 提交生活习惯 |
| POST | /api/survey/medical-history | 提交病史 |
| POST | /api/survey/family-history | 提交家族病史 |
| POST | /api/survey/allergy | 提交过敏信息 |
---
## 验收标准
- [ ] 获取调查状态接口正常
- [ ] 基础信息提交成功,BMI 自动计算
- [ ] 生活习惯提交成功
- [ ] 病史、家族史、过敏信息可多次添加
- [ ] 所有接口需要认证
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `04-后端开发/05-体质辨识模块.md`

83
TODOS/04-后端开发/05-体质辨识模块.md

@ -0,0 +1,83 @@
# 05-体质辨识模块
## 目标
实现中医体质辨识问卷功能,包括问卷题库、答案提交、体质计算和调养建议生成。
---
## 前置要求
- 健康调查模块已完成
- 数据模型已定义
---
## 实施步骤
详细代码请参考原文档 `02-后端开发/05-体质辨识模块.md`
### 主要任务
1. 创建体质常量定义 `internal/model/constitution_const.go`
- 九种体质类型常量
- 体质名称和特征描述
- 体质调养建议
2. 创建问卷题库初始化 `internal/database/seed.go`
- 60+ 道问卷题目
- 按体质类型分组
3. 创建体质 Repository `internal/repository/impl/constitution.go`
4. 创建体质计算 Service `internal/service/constitution.go`
- 分数计算算法
- 体质判定逻辑(平和质特殊判定)
5. 创建体质 Handler 并更新路由
---
## API 接口
| 方法 | 路径 | 说明 |
|-----|------|------|
| GET | /api/constitution/questions | 获取问卷题目 |
| POST | /api/constitution/submit | 提交问卷答案 |
| GET | /api/constitution/result | 获取最新结果 |
| GET | /api/constitution/history | 获取测评历史 |
---
## 体质计算公式
```
转化分 = (原始分 - 条目数) / (条目数 × 4) × 100
```
### 判定规则
- 平和质:平和质得分 ≥ 60 且其他体质 < 30
- 偏颇体质:得分 ≥ 40 为主要体质,≥ 30 为次要体质
---
## 验收标准
- [ ] 问卷题库自动初始化(60+ 题)
- [ ] 获取问卷接口返回所有题目
- [ ] 提交答案后正确计算体质得分
- [ ] 体质判定逻辑正确
- [ ] 调养建议正确返回
---
## 预计耗时
40-50 分钟
---
## 下一步
完成后进入 `04-后端开发/06-AI对话模块.md`

96
TODOS/04-后端开发/06-AI对话模块.md

@ -0,0 +1,96 @@
# 06-AI对话模块
## 目标
实现 AI 健康问诊对话功能,支持多轮对话、结合用户体质信息、流式响应。
---
## 前置要求
- 体质辨识模块已完成
- 已有 AI API Key(OpenAI / 通义千问)
---
## 实施步骤
详细代码请参考原文档 `02-后端开发/06-AI对话模块.md`
### 主要任务
1. 创建 AI 客户端抽象 `internal/service/ai/client.go`
2. 实现 OpenAI 客户端 `internal/service/ai/openai.go`
3. 实现阿里云通义千问客户端 `internal/service/ai/aliyun.go`
4. 创建 AI 客户端工厂 `internal/service/ai/factory.go`
5. 创建对话 Repository `internal/repository/impl/conversation.go`
6. 创建对话 Service `internal/service/conversation.go`
- 系统提示词模板
- 用户体质信息注入
- 产品推荐整合
7. 创建对话 Handler 并更新路由
---
## AI 配置
```yaml
ai:
provider: aliyun # openai, aliyun
max_history_messages: 10
openai:
api_key: "sk-xxx"
base_url: "https://api.openai.com/v1"
model: "gpt-3.5-turbo"
aliyun:
api_key: "sk-xxx"
model: "qwen-turbo"
```
---
## API 接口
| 方法 | 路径 | 说明 |
|-----|------|------|
| GET | /api/conversations | 获取对话列表 |
| POST | /api/conversations | 创建新对话 |
| GET | /api/conversations/:id | 获取对话详情 |
| DELETE | /api/conversations/:id | 删除对话 |
| POST | /api/conversations/:id/messages | 发送消息 |
---
## 系统提示词要点
1. 角色定义:健康咨询助理(非医师)
2. 紧急情况:胸痛、高烧等必须建议就医
3. 用户信息:性别、年龄、BMI
4. 体质信息:主体质、特征描述
5. 用药历史:近期用药记录
6. 产品推荐:体质相关保健品
---
## 验收标准
- [ ] 创建/获取/删除对话正常
- [ ] 发送消息返回 AI 回复
- [ ] AI 回复结合用户体质
- [ ] 对话历史正确保存
- [ ] 支持 OpenAI 和阿里云切换
- [ ] 紧急情况提示就医
---
## 预计耗时
40-50 分钟
---
## 下一步
完成后进入 `04-后端开发/07-健康档案模块.md`

96
TODOS/04-后端开发/07-健康档案模块.md

@ -0,0 +1,96 @@
# 07-健康档案模块
## 目标
实现用户健康档案的查询和管理功能,提供完整的健康信息视图。
---
## 前置要求
- 健康调查模块已完成
- 体质辨识模块已完成
---
## 实施步骤
详细代码请参考原文档 `02-后端开发/07-健康档案模块.md`
### 主要任务
1. 创建用户 Service `internal/service/user.go`
- 获取用户资料
- 更新用户资料
- 获取完整健康档案
2. 创建用户 Handler `internal/api/handler/user.go`
3. 更新完整路由配置
4. 更新主程序完整版
---
## API 接口
| 方法 | 路径 | 说明 | 认证 |
|-----|------|------|------|
| GET | /api/user/profile | 获取用户资料 | 是 |
| PUT | /api/user/profile | 更新用户资料 | 是 |
| GET | /api/user/health-profile | 获取健康档案 | 是 |
| PUT | /api/user/health-profile | 更新健康档案 | 是 |
| GET | /api/user/lifestyle | 获取生活习惯 | 是 |
| PUT | /api/user/lifestyle | 更新生活习惯 | 是 |
---
## 健康档案数据结构
```typescript
interface FullHealthProfile {
basic_info: {
name: string;
gender: string;
height: number;
weight: number;
bmi: number;
blood_type: string;
};
lifestyle: {
sleep_time: string;
wake_time: string;
exercise_frequency: string;
};
medical_history: Array<{ disease_name: string; status: string }>;
family_history: Array<{ relation: string; disease_name: string }>;
allergy_records: Array<{ allergen: string; severity: string }>;
constitution: {
primary_type: string;
primary_name: string;
assessed_at: string;
};
}
```
---
## 验收标准
- [ ] 获取用户资料正常
- [ ] 获取完整健康档案正常
- [ ] 更新资料和生活习惯正常
- [ ] 所有 API 接口可正常调用
- [ ] 服务器启动日志完整
---
## 预计耗时
30-40 分钟
---
## 下一步
完成后进入 `04-后端开发/08-保健品商城关联模块.md`

92
TODOS/04-后端开发/08-保健品商城关联模块.md

@ -0,0 +1,92 @@
# 08-保健品商城关联模块
## 目标
实现保健品数据管理和 AI 问诊时的产品推荐功能,关联外部保健品商城。
---
## 前置要求
- AI 对话模块已完成
- 体质辨识模块已完成
---
## 实施步骤
详细代码请参考原文档 `02-后端开发/08-保健品商城关联模块.md`
### 主要任务
1. 创建产品数据模型 `internal/model/product.go`
- Product 产品表
- ConstitutionProduct 体质-产品关联
- SymptomProduct 症状-产品关联
2. 创建种子数据 `internal/database/seed_products.go`
- 36 条模拟产品数据
- 体质-产品关联
- 症状-产品关联
3. 创建产品 Repository `internal/repository/impl/product.go`
4. 更新对话 Service,添加产品推荐
5. 创建产品 Handler 并更新路由
6. 更新主程序初始化产品数据
---
## API 接口
| 方法 | 路径 | 说明 | 认证 |
|-----|------|------|------|
| GET | /api/products | 获取产品列表 | 否 |
| GET | /api/products/:id | 获取产品详情 | 否 |
| GET | /api/products/recommend | 获取推荐产品 | 是 |
| GET | /api/products/search | 搜索产品 | 否 |
---
## 模拟数据统计
| 分类 | 数量 | 说明 |
|------|------|------|
| 体质调养类 | 20 | 补气、温阳、滋阴、祛湿、活血、理气、抗敏、综合 |
| 中老年常见类 | 16 | 心脑血管、骨关节、血糖、助眠、健脑、润肠、护眼、免疫 |
| **总计** | **36** | - |
---
## 产品推荐逻辑
```
1. 根据用户体质类型获取推荐产品
2. 根据对话中的症状关键词匹配产品
3. 综合推荐,去重后返回
```
---
## 验收标准
- [ ] 产品列表正常显示
- [ ] 按分类筛选正常
- [ ] 根据体质推荐产品正常
- [ ] 根据症状搜索产品正常
- [ ] AI 回答中包含产品推荐链接
- [ ] 种子数据正确初始化
---
## 预计耗时
30-40 分钟
---
## 下一步
后端开发全部完成!进入 `05-前后端对接/01-API服务对接.md`

299
TODOS/05-前后端对接/01-API服务对接.md

@ -0,0 +1,299 @@
# 01-API 服务对接
## 目标
将前端原型从模拟数据切换到真实后端 API。
---
## 前置要求
- APP/Web 原型开发完成
- 后端 API 服务运行中
---
## 对接步骤概览
### 1. 配置 API 基础地址
**APP (React Native):**
创建 `app/src/config/api.ts`
```typescript
// 开发环境配置
export const API_BASE_URL = __DEV__
? 'http://localhost:8080/api'
: 'https://api.yourservice.com/api';
export const TIMEOUT = 30000;
```
**Web (Vue):**
创建 `web/src/config/api.ts`
```typescript
export const API_BASE_URL = import.meta.env.DEV
? 'http://localhost:8080/api'
: 'https://api.yourservice.com/api';
```
---
### 2. 创建 HTTP 请求封装
**APP (React Native):**
创建 `app/src/api/client.ts`
```typescript
import AsyncStorage from '@react-native-async-storage/async-storage';
import { API_BASE_URL, TIMEOUT } from '../config/api';
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
headers?: Record<string, string>;
}
export async function apiRequest<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const token = await AsyncStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const data = await response.json();
if (data.code !== 0) {
throw new Error(data.message || '请求失败');
}
return data.data;
}
```
**Web (Vue):**
创建 `web/src/api/client.ts`
```typescript
import axios from 'axios';
import { ElMessage } from 'element-plus';
import { API_BASE_URL } from '../config/api';
import { useAuthStore } from '../stores/auth';
import router from '../router';
const client = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
});
// 请求拦截器
client.interceptors.request.use(config => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
});
// 响应拦截器
client.interceptors.response.use(
response => {
const { code, message, data } = response.data;
if (code !== 0) {
ElMessage.error(message || '请求失败');
return Promise.reject(new Error(message));
}
return data;
},
error => {
if (error.response?.status === 401) {
const authStore = useAuthStore();
authStore.logout();
router.push('/login');
}
ElMessage.error(error.message || '网络错误');
return Promise.reject(error);
}
);
export default client;
```
---
### 3. 创建 API 模块
创建各模块 API 文件:
**认证 API (`api/auth.ts`):**
```typescript
import client from './client';
export const authApi = {
login: (phone: string, password: string) =>
client.post('/auth/login', { phone, password }),
register: (phone: string, password: string, nickname?: string) =>
client.post('/auth/register', { phone, password, nickname }),
};
```
**体质 API (`api/constitution.ts`):**
```typescript
import client from './client';
export const constitutionApi = {
getQuestions: () =>
client.get('/constitution/questions'),
submit: (answers: { question_id: number; score: number }[]) =>
client.post('/constitution/submit', { answers }),
getResult: () =>
client.get('/constitution/result'),
getHistory: () =>
client.get('/constitution/history'),
};
```
**对话 API (`api/conversation.ts`):**
```typescript
import client from './client';
export const conversationApi = {
getList: () =>
client.get('/conversations'),
create: (title?: string) =>
client.post('/conversations', { title }),
getDetail: (id: string) =>
client.get(`/conversations/${id}`),
delete: (id: string) =>
client.delete(`/conversations/${id}`),
sendMessage: (id: string, content: string) =>
client.post(`/conversations/${id}/messages`, { content }),
};
```
**用户 API (`api/user.ts`):**
```typescript
import client from './client';
export const userApi = {
getProfile: () =>
client.get('/user/profile'),
updateProfile: (data: { nickname?: string; avatar?: string }) =>
client.put('/user/profile', data),
getHealthProfile: () =>
client.get('/user/health-profile'),
};
```
**产品 API (`api/product.ts`):**
```typescript
import client from './client';
export const productApi = {
getList: (category?: string) =>
client.get('/products', { params: { category } }),
getDetail: (id: number) =>
client.get(`/products/${id}`),
getRecommend: () =>
client.get('/products/recommend'),
search: (keyword: string) =>
client.get('/products/search', { params: { keyword } }),
};
```
---
### 4. 修改 Store 使用真实 API
**示例:修改认证 Store:**
```typescript
// stores/auth.ts (修改前 - 使用模拟数据)
function login(userData: User) {
user.value = userData;
token.value = 'mock-token-' + userData.id;
}
// stores/auth.ts (修改后 - 使用真实 API)
import { authApi } from '@/api/auth';
async function login(phone: string, password: string) {
const result = await authApi.login(phone, password);
user.value = {
id: result.user_id,
nickname: result.nickname,
phone,
surveyCompleted: result.survey_completed,
};
token.value = result.token;
localStorage.setItem('token', result.token);
}
```
---
### 5. 对接清单
| 功能模块 | 模拟数据 | 真实 API | 说明 |
|---------|---------|---------|------|
| 用户登录 | `mockLogin()` | `POST /auth/login` | 验证码改密码登录 |
| 用户注册 | - | `POST /auth/register` | 新增功能 |
| 体质问卷 | `constitutionQuestions` | `GET /constitution/questions` | - |
| 体质提交 | `calculateConstitution()` | `POST /constitution/submit` | 后端计算 |
| 体质结果 | `useConstitutionStore` | `GET /constitution/result` | - |
| 对话列表 | `useChatStore` | `GET /conversations` | - |
| 发送消息 | `mockAIReply()` | `POST /conversations/:id/messages` | AI 真实回复 |
| 产品推荐 | `mockProducts` | `GET /products/recommend` | - |
| 用户信息 | `useAuthStore` | `GET /user/profile` | - |
| 健康档案 | `mockProfile` | `GET /user/health-profile` | - |
---
## 验收标准
- [ ] 登录接口对接成功
- [ ] 体质问卷从后端获取
- [ ] 体质结果由后端计算
- [ ] AI 对话调用真实接口
- [ ] Token 认证正常工作
- [ ] 错误处理正常
---
## 预计耗时
60-90 分钟
---
## 下一步
完成后进入 `05-前后端对接/02-联调测试.md`

156
TODOS/05-前后端对接/02-联调测试.md

@ -0,0 +1,156 @@
# 02-联调测试
## 目标
完成前后端联调测试,确保所有功能正常运行。
---
## 前置要求
- API 服务对接完成
- 后端服务运行中
---
## 测试清单
### 1. 用户认证测试
| 测试项 | 操作 | 预期结果 |
|-------|------|---------|
| 注册 | 输入新手机号和密码 | 注册成功,返回 Token |
| 登录 | 输入正确手机号和密码 | 登录成功,跳转首页 |
| 登录失败 | 输入错误密码 | 显示"密码错误"提示 |
| Token 过期 | 使用过期 Token | 自动跳转登录页 |
### 2. 体质辨识测试
| 测试项 | 操作 | 预期结果 |
|-------|------|---------|
| 获取问卷 | 进入体质测试页 | 显示 60+ 题目 |
| 提交问卷 | 完成所有题目并提交 | 显示体质分析结果 |
| 查看结果 | 进入体质结果页 | 显示雷达图和建议 |
| 历史记录 | 查看测评历史 | 显示历史测评列表 |
### 3. AI 对话测试
| 测试项 | 操作 | 预期结果 |
|-------|------|---------|
| 创建对话 | 点击新建对话 | 创建成功 |
| 发送消息 | 输入健康问题 | AI 返回回复 |
| 体质相关 | 询问体质调养 | 回复包含用户体质建议 |
| 产品推荐 | 询问调养产品 | 回复包含产品链接 |
| 紧急情况 | 描述紧急症状 | AI 建议立即就医 |
| 删除对话 | 删除对话 | 对话删除成功 |
### 4. 用户信息测试
| 测试项 | 操作 | 预期结果 |
|-------|------|---------|
| 查看资料 | 进入个人中心 | 显示用户信息 |
| 更新昵称 | 修改昵称 | 更新成功 |
| 健康档案 | 查看健康档案 | 显示完整档案 |
### 5. 产品推荐测试
| 测试项 | 操作 | 预期结果 |
|-------|------|---------|
| 产品列表 | 查看产品列表 | 显示所有产品 |
| 分类筛选 | 选择分类 | 显示对应分类产品 |
| 个性推荐 | 查看推荐产品 | 根据体质推荐 |
| 产品搜索 | 搜索关键词 | 显示匹配产品 |
---
## 测试流程
### 完整流程测试
```
1. 新用户注册
2. 完成健康调查
3. 进行体质测试
4. 查看体质结果
5. 开始 AI 对话
6. 获取产品推荐
7. 查看健康档案
8. 退出登录
```
---
## 常见问题排查
### 网络错误
```
症状: 请求失败,网络错误
排查:
1. 检查后端服务是否运行: curl http://localhost:8080/health
2. 检查 API 地址配置是否正确
3. 检查 CORS 配置
```
### 认证错误
```
症状: 401 Unauthorized
排查:
1. 检查 Token 是否正确存储
2. 检查 Token 是否过期
3. 检查 Authorization Header 格式
```
### 数据错误
```
症状: 返回数据格式不对
排查:
1. 检查后端接口返回格式
2. 检查前端类型定义
3. 查看控制台 Network 响应
```
---
## 性能测试
| 测试项 | 目标 | 方法 |
|-------|------|------|
| 首页加载 | < 2s | Chrome DevTools |
| API 响应 | < 500ms | Network 面板 |
| AI 回复 | < 5s | 计时器 |
---
## 验收标准
- [ ] 所有功能测试通过
- [ ] 完整流程无报错
- [ ] 性能指标达标
- [ ] 错误处理正常
---
## 预计耗时
60-90 分钟
---
## 完成
恭喜!项目开发完成!
可选下一步:
- 部署上线
- 性能优化
- 功能迭代

4
agents.md

@ -0,0 +1,4 @@
# Agents 开发规范
- 涉及到任何代码修改,记得更新此文档和设计文档
-

1
app

@ -0,0 +1 @@
Subproject commit c44696ea7262b895945c31d230f4db0eaae9109a

1049
design.md

File diff suppressed because it is too large

BIN
files/ui/体质分析.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

BIN
files/ui/体质检测.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
files/ui/体质页.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

BIN
files/ui/我的.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
files/ui/登录页.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
files/ui/问答对话.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
files/ui/问答页.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

BIN
files/ui/首页.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

878
mall-design.md

@ -0,0 +1,878 @@
# 保健品商城 - 总体设计方案
> 关联项目:健康AI问询助手
> 开发顺序:在健康AI问询助手项目完成后开发
---
## 一、项目概述
### 1.1 项目背景
本项目是"健康AI问询助手"的关联项目,为用户提供保健品购买服务。通过AI问诊系统的体质分析和健康建议,智能推荐适合用户的保健品,实现"健康咨询 → 产品推荐 → 购买转化"的完整闭环。
### 1.2 核心价值
- **精准推荐**:基于用户体质和健康状况,提供个性化产品推荐
- **信任背书**:AI健康助手的专业分析增强用户购买信心
- **便捷体验**:从健康咨询到产品购买的无缝衔接
- **用户粘性**:健康管理与产品消费的双向绑定
### 1.3 目标用户
| 用户群体 | 特点 | 需求 |
|----------|------|------|
| 中老年人 | 50-70岁,注重养生 | 心脑血管、骨关节、助眠、血糖调节 |
| 亚健康白领 | 25-45岁,工作压力大 | 补气、抗疲劳、护眼、免疫力 |
| 养生爱好者 | 各年龄段,关注中医体质 | 体质调养、食疗产品 |
---
## 二、技术架构
### 2.1 技术栈
| 层次 | 技术选型 | 说明 |
|------|----------|------|
| Web前端 | Vue 3 + TypeScript + Vite | 与健康AI项目保持一致 |
| APP端 | React Native + TypeScript | 与健康AI项目保持一致 |
| 后端服务 | Go + Gin + GORM | 与健康AI项目保持一致 |
| 数据库 | SQLite / PostgreSQL | 支持切换,与健康AI共享用户表 |
| 支付对接 | 微信支付 / 支付宝 | 预留接口 |
| 对象存储 | 阿里云OSS / 本地存储 | 产品图片存储 |
### 2.2 系统架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ 用户端 │
├─────────────────────┬───────────────────────────────────────────┤
│ 健康AI Web/APP │ 商城 Web/APP │
│ (体质分析/AI问诊) │ (产品浏览/购物车/订单/支付) │
└─────────┬───────────┴────────────────┬──────────────────────────┘
│ │
│ API Gateway │
│ │
┌─────────▼────────────────────────────▼──────────────────────────┐
│ 后端服务层 │
├─────────────────────┬───────────────────────────────────────────┤
│ 健康AI服务 │ 商城服务 │
│ - 用户认证 │ - 产品管理 │
│ - 体质辨识 │ - 购物车 │
│ - AI对话 │ - 订单管理 │
│ - 健康档案 │ - 支付对接 │
│ - 产品推荐接口 │ - 物流查询 │
└─────────┬───────────┴────────────────┬──────────────────────────┘
│ │
┌─────────▼────────────────────────────▼──────────────────────────┐
│ 数据层 │
├─────────────────────┬───────────────────────────────────────────┤
│ 共享数据 │ 商城独立数据 │
│ - 用户表 │ - 产品详情表 │
│ - 产品基础表 │ - SKU表 │
│ - 体质-产品关联 │ - 购物车表 │
│ │ - 订单表 │
│ │ - 支付记录表 │
└─────────────────────┴───────────────────────────────────────────┘
```
### 2.3 与健康AI项目的双向对接
| 对接方式 | 方向 | 说明 |
|----------|------|------|
| 用户体系共享 | 双向 | 使用同一套用户认证(JWT Token互认) |
| 产品数据同步 | 双向 | 健康AI存储基础产品信息,商城扩展详细信息 |
| 产品推荐跳转 | 健康AI → 商城 | AI推荐的产品链接直接跳转到商城产品页 |
| AI咨询跳转 | 商城 → 健康AI | 商城产品页/首页提供AI咨询入口,跳转到健康AI |
| 体质信息获取 | 商城 ← 健康AI | 商城调用健康AI接口获取用户体质,优化推荐排序 |
| 购买记录同步 | 商城 → 健康AI | 用户购买保健品后,同步到健康AI用于AI问诊参考 |
---
## 三、功能模块
### 3.1 功能架构
```
保健品商城
├── 首页模块
│ ├── Banner轮播
│ ├── AI健康咨询入口(悬浮按钮)
│ ├── 体质推荐卡片(对接健康AI)
│ ├── 分类导航
│ ├── 热销推荐
│ └── 新品上架
├── 产品模块
│ ├── 产品列表(分类筛选、排序)
│ ├── 产品详情
│ │ ├── 图文规格评价
│ │ ├── "咨询适合我吗"按钮 → 跳转AI问诊
│ │ └── 相关体质说明
│ ├── 产品搜索
│ └── 收藏功能
├── AI咨询模块(跳转健康AI)
│ ├── 智能健康咨询入口
│ ├── 体质测试入口
│ ├── 历史咨询记录
│ └── 携带产品信息跳转
├── 购物车模块
│ ├── 添加/删除商品
│ ├── 修改数量
│ ├── 选择结算
│ └── 失效商品处理
├── 订单模块
│ ├── 确认订单
│ ├── 地址管理
│ ├── 订单列表
│ ├── 订单详情
│ ├── 取消订单
│ └── 申请退款
├── 支付模块
│ ├── 微信支付
│ ├── 支付宝支付
│ └── 支付结果回调
├── 用户模块
│ ├── 登录/注册(共享健康AI)
│ ├── 我的体质报告(跳转健康AI)
│ ├── 收货地址管理
│ ├── 我的订单
│ ├── 我的收藏
│ └── 浏览历史
└── 其他模块
├── 物流查询
└── 优惠券(扩展)
```
### 3.2 核心功能说明
#### 3.2.1 体质推荐功能
从健康AI获取用户体质信息,在商城首页和产品列表页优先展示适合用户体质的产品。
```
用户打开商城
调用健康AI接口获取用户体质
根据体质查询关联产品
在"为您推荐"模块展示
```
#### 3.2.2 智能搜索
支持按症状关键词搜索产品,如"失眠"、"关节痛"、"血压高"等。
#### 3.2.3 AI健康咨询功能(核心)
商城内置多个AI咨询入口,用户点击后跳转到健康AI项目进行咨询。
**入口位置:**
| 位置 | 入口形式 | 跳转目标 | 携带参数 |
|------|----------|----------|----------|
| 首页右下角 | 悬浮按钮"AI咨询" | 健康AI对话页 | 无 |
| 首页体质卡片 | "测测我的体质" | 健康AI体质测试页 | 无 |
| 产品详情页 | "这款适合我吗?" | 健康AI对话页 | 产品ID、产品名称 |
| 我的页面 | "查看体质报告" | 健康AI体质结果页 | 无 |
| 购物车页面 | "不知道选哪个?问问AI" | 健康AI对话页 | 购物车产品列表 |
**跳转流程:**
```
商城产品详情页
│ 用户点击"这款适合我吗?"
检查用户登录状态
├─[未登录]─▶ 跳转登录页 → 登录后继续
└─[已登录]─▶ 构建跳转URL
跳转健康AI对话页
URL: health-ai://chat?product_id=123&product_name=xxx
健康AI自动发起对话
"我想咨询一下【产品名称】是否适合我"
AI根据用户体质和产品信息回答
用户可继续咨询或返回商城购买
```
**跳转协议设计:**
```
# Web端跳转(同域或配置跨域)
https://health-ai.example.com/chat?source=mall&product_id=123
# APP端跳转(Deep Link)
health-ai://chat?source=mall&product_id=123&product_name=xxx
# 小程序跳转(如扩展)
/pages/chat/index?source=mall&product_id=123
```
**健康AI接收参数后处理:**
1. 识别来源为商城(source=mall)
2. 根据product_id获取产品信息
3. 自动生成用户问题:"我想了解【产品名称】是否适合我的体质"
4. AI结合用户体质数据回答
#### 3.2.4 购买记录同步
用户在商城购买保健品后,订单信息同步到健康AI,用于:
- AI问诊时参考用户已购买的保健品
- 避免重复推荐已购买产品
- 分析用户保健品使用效果
---
## 四、数据模型设计
### 4.1 ER图
```mermaid
erDiagram
User ||--o{ Order : places
User ||--o{ CartItem : has
User ||--o{ Address : has
User ||--o{ Favorite : has
Product ||--o{ ProductSku : has
Product ||--o{ CartItem : in
Product ||--o{ OrderItem : in
Product ||--o{ Favorite : contains
Order ||--|{ OrderItem : contains
Order ||--o| Payment : has
ProductSku ||--o{ CartItem : selected
ProductSku ||--o{ OrderItem : ordered
User {
int id PK
string phone
string nickname
string avatar
}
Product {
int id PK
string name
string category
string sub_category
text description
text detail_html
string main_image
json images
decimal original_price
decimal sale_price
int sales_count
int stock
boolean is_on_sale
int sort_order
}
ProductSku {
int id PK
int product_id FK
string sku_name
string sku_image
decimal price
int stock
json attributes
}
CartItem {
int id PK
int user_id FK
int product_id FK
int sku_id FK
int quantity
boolean selected
}
Address {
int id PK
int user_id FK
string receiver_name
string phone
string province
string city
string district
string detail
boolean is_default
}
Order {
int id PK
string order_no
int user_id FK
int address_id FK
decimal total_amount
decimal pay_amount
decimal freight_amount
string status
string pay_type
datetime pay_time
string logistics_no
text remark
datetime created_at
}
OrderItem {
int id PK
int order_id FK
int product_id FK
int sku_id FK
string product_name
string sku_name
string image
decimal price
int quantity
}
Payment {
int id PK
string payment_no
int order_id FK
string pay_type
decimal amount
string status
string trade_no
datetime paid_at
}
Favorite {
int id PK
int user_id FK
int product_id FK
datetime created_at
}
```
### 4.2 核心表说明
#### 产品表(Product)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 产品ID(与健康AI同步) |
| name | string | 产品名称 |
| category | string | 一级分类 |
| sub_category | string | 二级分类 |
| description | text | 简短描述 |
| detail_html | text | 详情页富文本 |
| main_image | string | 主图URL |
| images | json | 图片列表 |
| original_price | decimal | 原价 |
| sale_price | decimal | 售价 |
| sales_count | int | 销量 |
| stock | int | 库存 |
| is_on_sale | boolean | 是否上架 |
#### 订单表(Order)
| 字段 | 类型 | 说明 |
|------|------|------|
| order_no | string | 订单编号(唯一) |
| status | string | 状态:pending/paid/shipped/completed/cancelled/refunding |
| pay_type | string | 支付方式:wechat/alipay |
| logistics_no | string | 物流单号 |
### 4.3 订单状态机
```
待支付(pending)
├──[支付成功]──▶ 待发货(paid)
│ │
│ ├──[商家发货]──▶ 待收货(shipped)
│ │ │
│ │ └──[确认收货]──▶ 已完成(completed)
│ │
│ └──[申请退款]──▶ 退款中(refunding)
└──[取消/超时]──▶ 已取消(cancelled)
```
---
## 五、API 接口设计
### 5.1 产品接口
```
GET /api/products # 产品列表(支持分类、排序、分页)
GET /api/products/:id # 产品详情
GET /api/products/search?q=xxx # 搜索产品
GET /api/products/recommend # 体质推荐(调用健康AI)
GET /api/categories # 分类列表
```
### 5.2 购物车接口
```
GET /api/cart # 获取购物车
POST /api/cart # 添加到购物车
PUT /api/cart/:id # 更新数量
DELETE /api/cart/:id # 删除商品
PUT /api/cart/select-all # 全选/取消全选
```
### 5.3 订单接口
```
POST /api/orders # 创建订单
GET /api/orders # 订单列表
GET /api/orders/:id # 订单详情
PUT /api/orders/:id/cancel # 取消订单
PUT /api/orders/:id/confirm # 确认收货
POST /api/orders/:id/refund # 申请退款
```
### 5.4 支付接口
```
POST /api/pay/wechat # 微信支付
POST /api/pay/alipay # 支付宝支付
POST /api/pay/callback/wechat # 微信回调
POST /api/pay/callback/alipay # 支付宝回调
GET /api/pay/status/:order_no # 查询支付状态
```
### 5.5 地址接口
```
GET /api/addresses # 地址列表
POST /api/addresses # 添加地址
PUT /api/addresses/:id # 修改地址
DELETE /api/addresses/:id # 删除地址
PUT /api/addresses/:id/default # 设为默认
```
### 5.6 收藏接口
```
GET /api/favorites # 收藏列表
POST /api/favorites # 添加收藏
DELETE /api/favorites/:product_id # 取消收藏
```
---
## 六、项目目录结构
```
healthMall/
├── web/ # Web前端
│ ├── src/
│ │ ├── api/ # 接口定义
│ │ ├── assets/ # 静态资源
│ │ ├── components/ # 公共组件
│ │ │ ├── ProductCard.vue # 产品卡片
│ │ │ ├── CartItem.vue # 购物车项
│ │ │ └── AddressCard.vue # 地址卡片
│ │ ├── views/
│ │ │ ├── Home.vue # 首页
│ │ │ ├── Category.vue # 分类页
│ │ │ ├── ProductList.vue # 产品列表
│ │ │ ├── ProductDetail.vue # 产品详情
│ │ │ ├── Cart.vue # 购物车
│ │ │ ├── Checkout.vue # 结算页
│ │ │ ├── OrderList.vue # 订单列表
│ │ │ ├── OrderDetail.vue # 订单详情
│ │ │ └── Address.vue # 地址管理
│ │ ├── store/ # 状态管理
│ │ ├── router/ # 路由配置
│ │ └── utils/ # 工具函数
│ └── package.json
├── app/ # React Native APP
│ ├── src/
│ │ ├── api/
│ │ ├── components/
│ │ ├── screens/
│ │ ├── navigation/
│ │ ├── store/
│ │ └── utils/
│ └── package.json
├── server/ # 后端服务
│ ├── cmd/
│ │ └── main.go
│ ├── internal/
│ │ ├── api/
│ │ │ ├── handler/
│ │ │ │ ├── product.go
│ │ │ │ ├── cart.go
│ │ │ │ ├── order.go
│ │ │ │ ├── payment.go
│ │ │ │ └── address.go
│ │ │ ├── middleware/
│ │ │ └── router.go
│ │ ├── model/
│ │ │ ├── product.go
│ │ │ ├── cart.go
│ │ │ ├── order.go
│ │ │ ├── payment.go
│ │ │ └── address.go
│ │ ├── repository/
│ │ ├── service/
│ │ │ ├── product.go
│ │ │ ├── cart.go
│ │ │ ├── order.go
│ │ │ └── payment.go
│ │ ├── database/
│ │ └── config/
│ ├── pkg/
│ │ ├── payment/ # 支付SDK封装
│ │ │ ├── wechat.go
│ │ │ └── alipay.go
│ │ └── logistics/ # 物流查询
│ └── go.mod
├── config.yaml # 配置文件
└── README.md
```
---
## 七、UI 设计规范
### 7.1 设计原则
- 与健康AI项目保持视觉一致性
- 面向中老年用户,字体和按钮适当放大
- 购买流程清晰简洁,减少操作步骤
### 7.2 页面规划
| 页面 | 主要功能 | AI咨询入口 |
|------|----------|------------|
| 首页 | Banner、分类入口、体质推荐卡片、热销榜 | 右下角悬浮按钮、体质卡片"测测体质" |
| 分类页 | 左侧分类菜单、右侧产品列表 | 顶部"不知道选什么?问AI" |
| 产品详情 | 轮播图、价格、规格选择、加入购物车 | "这款适合我吗?咨询AI" |
| 购物车 | 商品列表、全选、价格汇总、去结算 | "不确定?咨询AI帮你选" |
| 结算页 | 收货地址、商品清单、支付方式、提交订单 | - |
| 订单列表 | Tab分类(全部/待付款/待发货/待收货/已完成) | - |
| 订单详情 | 物流信息、订单状态、商品信息、操作按钮 | "使用问题?咨询AI" |
| 我的 | 体质报告入口、订单、收藏、地址 | "查看我的体质报告" |
### 7.3 配色方案
| 用途 | 颜色 | 说明 |
|------|------|------|
| 主色调 | #52C41A | 绿色,与健康主题一致 |
| 价格色 | #FF4D4F | 红色,突出价格 |
| 辅助色 | #1890FF | 蓝色,链接和按钮 |
| 背景色 | #F5F5F5 | 浅灰,页面背景 |
| 文字色 | #333333 | 深灰,主要文字 |
---
## 八、开发计划
### 8.1 阶段划分
| 阶段 | 内容 | 预计周期 |
|------|------|----------|
| 第一阶段 | 产品展示、购物车 | - |
| 第二阶段 | 订单流程、地址管理 | - |
| 第三阶段 | 支付对接 | - |
| 第四阶段 | APP开发 | - |
| 第五阶段 | 物流、优惠券等扩展 | - |
### 8.2 开发任务清单
**后端开发:**
1. 项目初始化(共用健康AI的基础架构)
2. 产品模块(详情、SKU、库存)
3. 购物车模块
4. 地址管理模块
5. 订单模块(创建、状态流转)
6. 支付模块(微信/支付宝)
7. 健康AI对接(用户认证、体质推荐、订单同步)
**Web前端开发:**
1. 项目初始化
2. 首页(Banner、分类、推荐、AI咨询入口)
3. 产品列表和详情页(含AI咨询入口)
4. 购物车页面(含AI咨询入口)
5. 结算和订单页面
6. 地址管理页面
7. 我的页面(体质报告入口)
8. 健康AI跳转工具封装
**APP开发:**
1. 项目初始化
2. 导航和Tab配置
3. 各功能页面
4. Deep Link跳转健康AI配置
---
## 九、与健康AI的集成方案
### 9.1 用户认证集成
```go
// 商城服务验证Token时,调用健康AI的JWT验证
func ValidateToken(token string) (*UserClaims, error) {
// 使用相同的JWT密钥验证
// 或调用健康AI的 /api/auth/validate 接口
}
```
### 9.2 体质推荐集成
```go
// 商城首页获取用户体质推荐产品
func GetConstitutionRecommend(userID uint) ([]Product, error) {
// 1. 调用健康AI接口获取用户体质
// GET http://health-ai-server/api/constitution/result
// 2. 根据体质查询推荐产品
// SELECT * FROM products p
// JOIN constitution_products cp ON p.id = cp.product_id
// WHERE cp.constitution_type = ?
}
```
### 9.3 AI咨询跳转集成(商城 → 健康AI)
**Web端跳转工具:**
```typescript
// utils/healthAI.ts
const HEALTH_AI_BASE_URL = import.meta.env.VITE_HEALTH_AI_URL || 'http://localhost:5173'
interface JumpParams {
page: 'chat' | 'constitution' | 'result' // 对话页/体质测试/体质结果
productId?: number
productName?: string
source?: string
}
// 跳转到健康AI
export function jumpToHealthAI(params: JumpParams) {
const query = new URLSearchParams()
query.set('source', 'mall')
if (params.productId) {
query.set('product_id', String(params.productId))
}
if (params.productName) {
query.set('product_name', params.productName)
}
const url = `${HEALTH_AI_BASE_URL}/${params.page}?${query.toString()}`
// 同域:直接跳转
// 跨域:新窗口打开
window.location.href = url
}
// 在产品详情页使用
// jumpToHealthAI({ page: 'chat', productId: 123, productName: '氨糖软骨素' })
```
**APP端跳转(React Native):**
```typescript
// utils/healthAI.ts
import { Linking, Platform } from 'react-native'
const HEALTH_AI_SCHEME = 'healthai://'
const HEALTH_AI_WEB_URL = 'https://health-ai.example.com'
export async function jumpToHealthAI(params: {
page: 'chat' | 'constitution' | 'result'
productId?: number
productName?: string
}) {
const query = `source=mall&product_id=${params.productId || ''}&product_name=${encodeURIComponent(params.productName || '')}`
// 尝试Deep Link
const deepLink = `${HEALTH_AI_SCHEME}${params.page}?${query}`
const canOpen = await Linking.canOpenURL(deepLink)
if (canOpen) {
// 健康AI APP已安装,使用Deep Link
await Linking.openURL(deepLink)
} else {
// 健康AI APP未安装,跳转Web版
await Linking.openURL(`${HEALTH_AI_WEB_URL}/${params.page}?${query}`)
}
}
```
**健康AI接收跳转参数处理:**
```typescript
// 健康AI项目 - chat页面
// views/Chat.vue 或 screens/Chat.tsx
import { useRoute } from 'vue-router' // Vue
// import { useRoute } from '@react-navigation/native' // RN
const route = useRoute()
onMounted(() => {
const { source, product_id, product_name } = route.query
if (source === 'mall' && product_id) {
// 从商城跳转而来,自动发起产品咨询
const autoMessage = `我想了解【${product_name}】这款产品是否适合我的体质?`
sendMessage(autoMessage)
}
})
```
### 9.4 双向跳转入口汇总
| 方向 | 场景 | 入口 | 跳转目标 |
|------|------|------|----------|
| 商城→健康AI | 首页悬浮按钮 | "AI咨询" | 健康AI对话页 |
| 商城→健康AI | 首页体质卡片 | "测测我的体质" | 健康AI体质测试页 |
| 商城→健康AI | 产品详情页 | "这款适合我吗?" | 健康AI对话页(带产品参数) |
| 商城→健康AI | 购物车页 | "不确定选哪个?" | 健康AI对话页 |
| 商城→健康AI | 我的页面 | "查看体质报告" | 健康AI体质结果页 |
| 健康AI→商城 | AI回答推荐 | 产品链接 | 商城产品详情页 |
| 健康AI→商城 | 体质结果页 | "选购调养产品" | 商城体质推荐列表 |
### 9.5 订单数据同步
用户在商城购买后,同步订单到健康AI用于AI问诊参考:
```go
// 商城后端 - 订单完成后同步
func SyncOrderToHealthAI(order *Order) error {
payload := map[string]interface{}{
"user_id": order.UserID,
"order_no": order.OrderNo,
"products": extractProductInfo(order.Items),
"created_at": order.CreatedAt,
}
// 调用健康AI接口同步
_, err := http.Post(
config.HealthAI.BaseURL + "/api/sync/purchase",
"application/json",
bytes.NewBuffer(jsonEncode(payload)),
)
return err
}
```
```go
// 健康AI后端 - 接收购买记录
// POST /api/sync/purchase
func HandlePurchaseSync(c *gin.Context) {
var req struct {
UserID uint `json:"user_id"`
OrderNo string `json:"order_no"`
Products []struct {
ID uint `json:"id"`
Name string `json:"name"`
} `json:"products"`
}
c.BindJSON(&req)
// 存储到用户健康档案,AI问诊时可参考
// "用户最近购买了:氨糖软骨素、深海鱼油..."
}
```
---
## 十、安全与合规
### 10.1 支付安全
- 支付回调验签
- 订单金额校验
- 防止重复支付
### 10.2 数据安全
- 用户隐私数据加密存储
- HTTPS传输
- SQL注入防护
### 10.3 合规要求
- 保健品销售资质展示
- 产品不得宣传治疗功效
- 退换货政策明示
---
## 附录:配置文件示例
```yaml
# config.yaml
server:
port: 8081
mode: debug
database:
driver: sqlite
dsn: "./data/mall.db"
# 健康AI服务配置
health_ai:
# 后端API地址(用于数据同步)
api_url: "http://localhost:8080"
# Web前端地址(用于页面跳转)
web_url: "http://localhost:5173"
# APP Deep Link Scheme
app_scheme: "healthai://"
# 同步API密钥
sync_api_key: "your-sync-key"
# 支付配置(正式环境从环境变量读取)
payment:
wechat:
app_id: ""
mch_id: ""
api_key: ""
notify_url: "https://yourdomain.com/api/pay/callback/wechat"
alipay:
app_id: ""
private_key: ""
public_key: ""
notify_url: "https://yourdomain.com/api/pay/callback/alipay"
# 文件存储
storage:
type: local # local / oss
local:
path: "./uploads"
oss:
endpoint: ""
access_key: ""
secret_key: ""
bucket: ""
```
---
> **文档版本**: v1.0
> **创建日期**: 2026-02-01
> **关联项目**: 健康AI问询助手

30
scripts/start-all.bat

@ -0,0 +1,30 @@
@echo off
chcp 65001 >nul
echo ====================================
echo 健康AI助手 - 原型启动脚本
echo ====================================
echo.
echo 即将启动:
echo - Web 原型 (http://localhost:5173)
echo - APP 原型 (Expo)
echo.
echo 测试账号: 13800138000 / 123456
echo ====================================
echo.
:: 启动 Web 开发服务器
start "Web Dev Server" cmd /c "cd /d "%~dp0..\web" && npm run dev"
:: 等待 2 秒
timeout /t 2 /nobreak >nul
:: 启动 APP 开发服务器
start "APP Dev Server" cmd /c "cd /d "%~dp0..\app" && npx expo start --web"
echo.
echo 服务器已在新窗口中启动!
echo.
echo Web: http://localhost:5173
echo APP: http://localhost:8081 (浏览器预览)
echo.
pause

13
scripts/start-app.bat

@ -0,0 +1,13 @@
@echo off
echo ====================================
echo Starting APP (React Native)
echo ====================================
echo.
cd /d "%~dp0..\app"
echo Scan QR code to preview on phone
echo Or press 'w' for web preview
echo Test: 13800138000 / 123456
echo Press Ctrl+C to stop
echo ====================================
echo.
npx expo start

12
scripts/start-web.bat

@ -0,0 +1,12 @@
@echo off
echo ====================================
echo Starting Web (Vue 3 + Vite)
echo ====================================
echo.
cd /d "%~dp0..\web"
echo URL: http://localhost:5173
echo Test: 13800138000 / 123456
echo Press Ctrl+C to stop
echo ====================================
echo.
npm run dev

57
server/cmd/server/main.go

@ -0,0 +1,57 @@
package main
import (
"fmt"
"log"
"health-ai/internal/api"
"health-ai/internal/config"
"health-ai/internal/database"
"health-ai/internal/model"
"health-ai/pkg/jwt"
)
func main() {
log.Println("Health AI Server Starting...")
// 加载配置
if err := config.LoadConfig("config.yaml"); err != nil {
log.Fatalf("Failed to load config: %v", err)
}
log.Println("Config loaded")
// 初始化数据库
if err := database.InitDatabase(&config.AppConfig.Database); err != nil {
log.Fatalf("Failed to init database: %v", err)
}
log.Println("Database connected")
// 自动迁移
if err := database.AutoMigrate(model.AllModels()...); err != nil {
log.Fatalf("Failed to migrate: %v", err)
}
log.Println("Database migrated")
// 初始化问卷题库
if err := database.SeedQuestionBank(); err != nil {
log.Fatalf("Failed to seed question bank: %v", err)
}
// 创建测试用户
if err := database.SeedTestUser(); err != nil {
log.Printf("Warning: Failed to create test user: %v", err)
}
// 初始化 JWT
jwt.Init(config.AppConfig.JWT.Secret, config.AppConfig.JWT.ExpireHours)
log.Println("JWT initialized")
// 启动服务器
router := api.SetupRouter(config.AppConfig.Server.Mode)
addr := fmt.Sprintf(":%d", config.AppConfig.Server.Port)
log.Printf("Server running on http://localhost%s", addr)
if err := router.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

40
server/config.yaml

@ -0,0 +1,40 @@
server:
port: 8080
mode: debug # debug, release, test
database:
driver: sqlite # sqlite, postgres, mysql
sqlite:
path: ./data/health.db
postgres:
host: localhost
port: 5432
user: postgres
password: ""
dbname: health_app
mysql:
host: localhost
port: 3306
user: root
password: ""
dbname: health_app
jwt:
secret: health-ai-secret-key-change-in-production
expire_hours: 24
ai:
provider: aliyun # openai, aliyun (通义千问)
max_history_messages: 10
max_tokens: 2000
# OpenAI 配置
openai:
api_key: ""
base_url: "https://api.openai.com/v1"
model: "gpt-3.5-turbo"
# 阿里云通义千问配置
aliyun:
api_key: "" # 请填入您的 DashScope API Key
model: "qwen-turbo" # 可选: qwen-turbo, qwen-plus, qwen-max

BIN
server/data/health.db

Binary file not shown.

551
server/docs/API.md

@ -0,0 +1,551 @@
# 健康AI问询助手 - 后端API文档
> 后端服务地址: `http://localhost:8080`
>
> 所有需要认证的接口,请在Header中添加: `Authorization: Bearer <token>`
---
## 一、认证接口
### 1.1 用户注册
- **POST** `/api/auth/register`
**请求体:**
```json
{
"phone": "13800138000",
"password": "123456",
"nickname": "用户昵称" // 可选
}
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"token": "eyJhbGc...",
"user_id": 1,
"nickname": "用户昵称",
"avatar": "",
"survey_completed": false
}
}
```
### 1.2 用户登录
- **POST** `/api/auth/login`
**请求体:**
```json
{
"phone": "13800138000",
"password": "123456"
}
```
**响应:** 同注册接口
### 1.3 刷新Token
- **POST** `/api/auth/refresh`
- **需要认证**
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"token": "新的token"
}
}
```
---
## 二、用户接口
### 2.1 获取用户信息
- **GET** `/api/user/profile`
- **需要认证**
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"user_id": 1,
"phone": "13800138000",
"email": "",
"nickname": "用户昵称",
"avatar": "",
"survey_completed": false
}
}
```
### 2.2 更新用户资料
- **PUT** `/api/user/profile`
- **需要认证**
**请求体:**
```json
{
"nickname": "新昵称",
"avatar": "头像URL"
}
```
---
## 三、健康调查接口
### 3.1 获取调查状态
- **GET** `/api/survey/status`
- **需要认证**
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"basic_info": true,
"lifestyle": false,
"medical_history": false,
"family_history": false,
"allergy": false,
"all_completed": false
}
}
```
### 3.2 提交基础信息
- **POST** `/api/survey/basic-info`
- **需要认证**
**请求体:**
```json
{
"name": "张三",
"birth_date": "1990-05-15",
"gender": "male", // male/female
"height": 175, // cm
"weight": 70, // kg
"blood_type": "A", // A/B/AB/O
"occupation": "工程师",
"marital_status": "married", // single/married/divorced
"region": "北京"
}
```
### 3.3 提交生活习惯
- **POST** `/api/survey/lifestyle`
- **需要认证**
**请求体:**
```json
{
"sleep_time": "23:00",
"wake_time": "07:00",
"sleep_quality": "normal", // good/normal/poor
"meal_regularity": "regular", // regular/irregular
"diet_preference": "清淡",
"daily_water_ml": 2000,
"exercise_frequency": "sometimes", // never/sometimes/often/daily
"exercise_type": "跑步",
"exercise_duration_min": 30,
"is_smoker": false,
"alcohol_frequency": "never" // never/sometimes/often
}
```
### 3.4 提交病史
- **POST** `/api/survey/medical-history`
- **需要认证**
**请求体:**
```json
{
"disease_name": "高血压",
"disease_type": "chronic", // chronic/surgery/other
"diagnosed_date": "2020-01",
"status": "controlled", // cured/treating/controlled
"notes": "备注信息"
}
```
### 3.5 批量提交病史(覆盖式)
- **POST** `/api/survey/medical-history/batch`
- **需要认证**
**请求体:**
```json
{
"histories": [
{
"disease_name": "高血压",
"disease_type": "chronic",
"diagnosed_date": "2020-01",
"status": "controlled",
"notes": ""
}
]
}
```
### 3.6 提交家族病史
- **POST** `/api/survey/family-history`
- **需要认证**
**请求体:**
```json
{
"relation": "father", // father/mother/grandparent
"disease_name": "糖尿病",
"notes": ""
}
```
### 3.7 提交过敏信息
- **POST** `/api/survey/allergy`
- **需要认证**
**请求体:**
```json
{
"allergy_type": "drug", // drug/food/other
"allergen": "青霉素",
"severity": "moderate", // mild/moderate/severe
"reaction_desc": "皮疹"
}
```
### 3.8 完成调查
- **POST** `/api/survey/complete`
- **需要认证**
---
## 四、体质辨识接口
### 4.1 获取问卷题目
- **GET** `/api/constitution/questions`
- **需要认证**
**响应:**
```json
{
"code": 0,
"message": "success",
"data": [
{
"id": 1,
"constitution_type": "pinghe",
"question_text": "您精力充沛吗?",
"options": "[\"没有\",\"很少\",\"有时\",\"经常\",\"总是\"]",
"order_num": 1
}
]
}
```
### 4.2 获取分组的问卷题目
- **GET** `/api/constitution/questions/grouped`
- **需要认证**
### 4.3 提交测评
- **POST** `/api/constitution/submit`
- **需要认证**
**请求体:**
```json
{
"answers": [
{"question_id": 1, "score": 3},
{"question_id": 2, "score": 2}
// ... 所有题目的答案,score: 1-5 对应选项
]
}
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"primary_constitution": {
"type": "qixu",
"name": "气虚质",
"score": 65.5,
"description": "元气不足,容易疲劳..."
},
"secondary_constitutions": [],
"all_scores": [
{"type": "qixu", "name": "气虚质", "score": 65.5, "description": "..."},
{"type": "yangxu", "name": "阳虚质", "score": 45.2, "description": "..."}
],
"recommendations": {
"qixu": {
"diet": "宜食益气健脾食物...",
"lifestyle": "避免劳累...",
"exercise": "宜柔和运动...",
"emotion": "避免过度思虑"
}
},
"assessed_at": "2026-02-01T16:30:00Z"
}
}
```
### 4.4 获取最新测评结果
- **GET** `/api/constitution/result`
- **需要认证**
### 4.5 获取测评历史
- **GET** `/api/constitution/history?limit=10`
- **需要认证**
### 4.6 获取调养建议
- **GET** `/api/constitution/recommendations`
- **需要认证**
---
## 五、AI对话接口
### 5.1 获取对话列表
- **GET** `/api/conversations`
- **需要认证**
**响应:**
```json
{
"code": 0,
"message": "success",
"data": [
{
"id": 1,
"title": "新对话 02-01 16:30",
"created_at": "2026-02-01T16:30:00Z",
"updated_at": "2026-02-01T16:35:00Z"
}
]
}
```
### 5.2 创建新对话
- **POST** `/api/conversations`
- **需要认证**
**请求体:**
```json
{
"title": "对话标题" // 可选
}
```
### 5.3 获取对话详情
- **GET** `/api/conversations/:id`
- **需要认证**
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"title": "最近总是感觉疲劳...",
"messages": [
{
"id": 1,
"role": "user",
"content": "最近总是感觉疲劳怎么办?",
"created_at": "2026-02-01T16:30:00Z"
},
{
"id": 2,
"role": "assistant",
"content": "【情况分析】...",
"created_at": "2026-02-01T16:30:05Z"
}
],
"created_at": "2026-02-01T16:30:00Z",
"updated_at": "2026-02-01T16:30:05Z"
}
}
```
### 5.4 删除对话
- **DELETE** `/api/conversations/:id`
- **需要认证**
### 5.5 发送消息
- **POST** `/api/conversations/:id/messages`
- **需要认证**
**请求体:**
```json
{
"content": "我最近总是感觉疲劳怎么办?"
}
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"id": 2,
"role": "assistant",
"content": "【情况分析】根据您的描述...\n【建议】\n1. 保证充足睡眠...",
"created_at": "2026-02-01T16:30:05Z"
}
}
```
---
## 六、健康档案接口
### 6.1 获取完整健康档案
- **GET** `/api/user/health-profile`
- **需要认证**
### 6.2 获取基础档案
- **GET** `/api/user/basic-profile`
- **需要认证**
### 6.3 获取生活习惯
- **GET** `/api/user/lifestyle`
- **需要认证**
### 6.4 获取病史列表
- **GET** `/api/user/medical-history`
- **需要认证**
### 6.5 删除病史记录
- **DELETE** `/api/user/medical-history/:id`
- **需要认证**
### 6.6 获取家族病史
- **GET** `/api/user/family-history`
- **需要认证**
### 6.7 获取过敏记录
- **GET** `/api/user/allergy-records`
- **需要认证**
---
## 七、产品接口
### 7.1 获取产品列表
- **GET** `/api/products?page=1&page_size=20`
### 7.2 获取产品详情
- **GET** `/api/products/:id`
### 7.3 按分类获取产品
- **GET** `/api/products/category?category=补气类`
### 7.4 搜索产品
- **GET** `/api/products/search?keyword=疲劳`
### 7.5 获取推荐产品(基于用户体质)
- **GET** `/api/products/recommend`
- **需要认证**
### 7.6 获取购买历史
- **GET** `/api/user/purchase-history`
- **需要认证**
---
## 八、商城同步接口
### 8.1 同步购买记录
- **POST** `/api/sync/purchase`
**请求体:**
```json
{
"user_id": 1,
"order_no": "ORDER123456",
"products": [
{"id": 1, "name": "黄芪精"},
{"id": 2, "name": "人参蜂王浆"}
],
"created_at": "2026-02-01T16:00:00Z"
}
```
---
## 九、错误码说明
| Code | 说明 |
|------|------|
| 0 | 成功 |
| 400 | 参数错误 |
| 401 | 未授权/Token无效 |
| 403 | 禁止访问 |
| 404 | 资源不存在 |
| 500 | 服务器错误 |
---
## 十、体质类型对照
| 类型代码 | 中文名称 |
|----------|----------|
| pinghe | 平和质 |
| qixu | 气虚质 |
| yangxu | 阳虚质 |
| yinxu | 阴虚质 |
| tanshi | 痰湿质 |
| shire | 湿热质 |
| xueyu | 血瘀质 |
| qiyu | 气郁质 |
| tebing | 特禀质 |
---
## 十一、配置说明
后端配置文件 `config.yaml`
```yaml
server:
port: 8080
mode: debug
database:
driver: sqlite
sqlite:
path: ./data/health.db
jwt:
secret: your-secret-key
expire_hours: 24
ai:
provider: aliyun # 或 openai
max_history_messages: 10
aliyun:
api_key: "您的阿里云DashScope API Key"
model: "qwen-turbo"
```
---
## 联系方式
如有接口问题,请创建 Issue 或联系后端开发团队。

58
server/go.mod

@ -0,0 +1,58 @@
module health-ai
go 1.25.5
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.47.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

133
server/go.sum

@ -0,0 +1,133 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

141
server/internal/api/handler/auth.go

@ -0,0 +1,141 @@
package handler
import (
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler() *AuthHandler {
return &AuthHandler{
authService: service.NewAuthService(),
}
}
// Register 用户注册
// @Summary 用户注册
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body service.RegisterRequest true "注册信息"
// @Success 200 {object} response.Response{data=service.AuthResponse}
// @Router /api/auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
var req service.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
result, err := h.authService.Register(&req)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, result)
}
// Login 用户登录
// @Summary 用户登录
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body service.LoginRequest true "登录信息"
// @Success 200 {object} response.Response{data=service.AuthResponse}
// @Router /api/auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req service.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
result, err := h.authService.Login(&req)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, result)
}
// RefreshToken 刷新Token
// @Summary 刷新Token
// @Tags 认证
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=map[string]string}
// @Router /api/auth/refresh [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
// 从header获取旧token
oldToken := c.GetHeader("Authorization")
if len(oldToken) > 7 {
oldToken = oldToken[7:] // 去掉 "Bearer "
}
newToken, err := h.authService.RefreshToken(oldToken)
if err != nil {
response.Unauthorized(c, "Token刷新失败")
return
}
response.Success(c, gin.H{"token": newToken})
}
// GetUserInfo 获取当前用户信息
// @Summary 获取当前用户信息
// @Tags 用户
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=service.UserInfoResponse}
// @Router /api/user/profile [get]
func (h *AuthHandler) GetUserInfo(c *gin.Context) {
userID := middleware.GetUserID(c)
result, err := h.authService.GetUserInfo(userID)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, result)
}
// UpdateProfile 更新用户资料
// @Summary 更新用户资料
// @Tags 用户
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body UpdateProfileRequest true "更新信息"
// @Success 200 {object} response.Response
// @Router /api/user/profile [put]
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.authService.UpdateProfile(userID, req.Nickname, req.Avatar); err != nil {
response.Error(c, 400, err.Error())
return
}
response.SuccessWithMessage(c, "更新成功", nil)
}
// UpdateProfileRequest 更新资料请求
type UpdateProfileRequest struct {
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}

155
server/internal/api/handler/constitution.go

@ -0,0 +1,155 @@
package handler
import (
"strconv"
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type ConstitutionHandler struct {
constitutionService *service.ConstitutionService
}
func NewConstitutionHandler() *ConstitutionHandler {
return &ConstitutionHandler{
constitutionService: service.NewConstitutionService(),
}
}
// GetQuestions 获取体质问卷题目
// @Summary 获取体质问卷题目
// @Tags 体质辨识
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=[]model.QuestionBank}
// @Router /api/constitution/questions [get]
func (h *ConstitutionHandler) GetQuestions(c *gin.Context) {
questions, err := h.constitutionService.GetQuestions()
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, questions)
}
// GetQuestionsGrouped 获取分组的体质问卷题目
// @Summary 获取分组的体质问卷题目
// @Tags 体质辨识
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=[]service.QuestionGroup}
// @Router /api/constitution/questions/grouped [get]
func (h *ConstitutionHandler) GetQuestionsGrouped(c *gin.Context) {
groups, err := h.constitutionService.GetQuestionsGrouped()
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, groups)
}
// SubmitAssessment 提交体质测评
// @Summary 提交体质测评答案
// @Tags 体质辨识
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.SubmitAssessmentRequest true "测评答案"
// @Success 200 {object} response.Response{data=service.AssessmentResult}
// @Router /api/constitution/submit [post]
func (h *ConstitutionHandler) SubmitAssessment(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.SubmitAssessmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if len(req.Answers) == 0 {
response.BadRequest(c, "请至少回答一道问题")
return
}
result, err := h.constitutionService.SubmitAssessment(userID, &req)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
// GetResult 获取最新体质测评结果
// @Summary 获取最新体质测评结果
// @Tags 体质辨识
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=service.AssessmentResult}
// @Router /api/constitution/result [get]
func (h *ConstitutionHandler) GetResult(c *gin.Context) {
userID := middleware.GetUserID(c)
result, err := h.constitutionService.GetLatestResult(userID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, result)
}
// GetHistory 获取体质测评历史
// @Summary 获取体质测评历史
// @Tags 体质辨识
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param limit query int false "返回数量限制,默认10"
// @Success 200 {object} response.Response{data=[]service.AssessmentResult}
// @Router /api/constitution/history [get]
func (h *ConstitutionHandler) GetHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
results, err := h.constitutionService.GetHistory(userID, limit)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, results)
}
// GetRecommendations 获取体质调养建议
// @Summary 获取体质调养建议
// @Tags 体质辨识
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=map[string]map[string]string}
// @Router /api/constitution/recommendations [get]
func (h *ConstitutionHandler) GetRecommendations(c *gin.Context) {
userID := middleware.GetUserID(c)
recommendations, err := h.constitutionService.GetRecommendations(userID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, recommendations)
}

183
server/internal/api/handler/conversation.go

@ -0,0 +1,183 @@
package handler
import (
"strconv"
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type ConversationHandler struct {
convService *service.ConversationService
}
func NewConversationHandler() *ConversationHandler {
return &ConversationHandler{
convService: service.NewConversationService(),
}
}
// GetConversations 获取对话列表
// @Summary 获取对话列表
// @Tags AI对话
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=[]service.ConversationResponse}
// @Router /api/conversations [get]
func (h *ConversationHandler) GetConversations(c *gin.Context) {
userID := middleware.GetUserID(c)
convs, err := h.convService.GetConversations(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, convs)
}
// CreateConversation 创建新对话
// @Summary 创建新对话
// @Tags AI对话
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.CreateConversationRequest true "对话标题(可选)"
// @Success 200 {object} response.Response{data=service.ConversationResponse}
// @Router /api/conversations [post]
func (h *ConversationHandler) CreateConversation(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.CreateConversationRequest
c.ShouldBindJSON(&req)
conv, err := h.convService.CreateConversation(userID, req.Title)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, conv)
}
// GetConversation 获取对话详情
// @Summary 获取对话详情(含消息历史)
// @Tags AI对话
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param id path int true "对话ID"
// @Success 200 {object} response.Response{data=service.ConversationResponse}
// @Router /api/conversations/{id} [get]
func (h *ConversationHandler) GetConversation(c *gin.Context) {
userID := middleware.GetUserID(c)
convID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的对话ID")
return
}
conv, err := h.convService.GetConversation(userID, uint(convID))
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, conv)
}
// DeleteConversation 删除对话
// @Summary 删除对话
// @Tags AI对话
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param id path int true "对话ID"
// @Success 200 {object} response.Response
// @Router /api/conversations/{id} [delete]
func (h *ConversationHandler) DeleteConversation(c *gin.Context) {
userID := middleware.GetUserID(c)
convID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的对话ID")
return
}
if err := h.convService.DeleteConversation(userID, uint(convID)); err != nil {
response.Error(c, 400, err.Error())
return
}
response.SuccessWithMessage(c, "对话已删除", nil)
}
// SendMessage 发送消息
// @Summary 发送消息并获取AI回复
// @Tags AI对话
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param id path int true "对话ID"
// @Param request body service.SendMessageRequest true "消息内容"
// @Success 200 {object} response.Response{data=service.MessageResponse}
// @Router /api/conversations/{id}/messages [post]
func (h *ConversationHandler) SendMessage(c *gin.Context) {
userID := middleware.GetUserID(c)
convID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的对话ID")
return
}
var req service.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请输入消息内容")
return
}
if req.Content == "" {
response.BadRequest(c, "消息内容不能为空")
return
}
reply, err := h.convService.SendMessage(c.Request.Context(), userID, uint(convID), req.Content)
if err != nil {
response.Error(c, 500, "AI回复失败: "+err.Error())
return
}
response.Success(c, reply)
}
// SendMessageStream 流式发送消息(SSE)
// @Summary 流式发送消息
// @Tags AI对话
// @Accept json
// @Produce text/event-stream
// @Param Authorization header string true "Bearer Token"
// @Param id path int true "对话ID"
// @Param request body service.SendMessageRequest true "消息内容"
// @Router /api/conversations/{id}/messages/stream [post]
func (h *ConversationHandler) SendMessageStream(c *gin.Context) {
userID := middleware.GetUserID(c)
convID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的对话ID")
return
}
var req service.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请输入消息内容")
return
}
// 设置 SSE 响应头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
err = h.convService.SendMessageStream(c.Request.Context(), userID, uint(convID), req.Content, c.Writer)
if err != nil {
c.SSEvent("error", err.Error())
}
c.SSEvent("done", "")
}

201
server/internal/api/handler/health.go

@ -0,0 +1,201 @@
package handler
import (
"strconv"
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type HealthHandler struct {
healthService *service.HealthService
}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{
healthService: service.NewHealthService(),
}
}
// GetHealthProfile 获取完整健康档案
// @Summary 获取完整健康档案
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=service.HealthProfileResponse}
// @Router /api/user/health-profile [get]
func (h *HealthHandler) GetHealthProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
profile, err := h.healthService.GetHealthProfile(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, profile)
}
// GetBasicProfile 获取基础档案
// @Summary 获取基础档案
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=model.HealthProfile}
// @Router /api/user/basic-profile [get]
func (h *HealthHandler) GetBasicProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
profile, err := h.healthService.GetBasicProfile(userID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, profile)
}
// GetLifestyle 获取生活习惯
// @Summary 获取生活习惯
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=model.LifestyleInfo}
// @Router /api/user/lifestyle [get]
func (h *HealthHandler) GetLifestyle(c *gin.Context) {
userID := middleware.GetUserID(c)
lifestyle, err := h.healthService.GetLifestyle(userID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, lifestyle)
}
// GetMedicalHistory 获取病史列表
// @Summary 获取病史列表
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=[]model.MedicalHistory}
// @Router /api/user/medical-history [get]
func (h *HealthHandler) GetMedicalHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
history, err := h.healthService.GetMedicalHistory(userID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, history)
}
// GetFamilyHistory 获取家族病史列表
// @Summary 获取家族病史列表
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=[]model.FamilyHistory}
// @Router /api/user/family-history [get]
func (h *HealthHandler) GetFamilyHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
history, err := h.healthService.GetFamilyHistory(userID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, history)
}
// GetAllergyRecords 获取过敏记录列表
// @Summary 获取过敏记录列表
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=[]model.AllergyRecord}
// @Router /api/user/allergy-records [get]
func (h *HealthHandler) GetAllergyRecords(c *gin.Context) {
userID := middleware.GetUserID(c)
records, err := h.healthService.GetAllergyRecords(userID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, records)
}
// DeleteMedicalHistory 删除病史记录
// @Summary 删除病史记录
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param id path int true "记录ID"
// @Success 200 {object} response.Response
// @Router /api/user/medical-history/{id} [delete]
func (h *HealthHandler) DeleteMedicalHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的ID")
return
}
if err := h.healthService.DeleteMedicalHistory(userID, uint(id)); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "删除成功", nil)
}
// DeleteFamilyHistory 删除家族病史记录
// @Summary 删除家族病史记录
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param id path int true "记录ID"
// @Success 200 {object} response.Response
// @Router /api/user/family-history/{id} [delete]
func (h *HealthHandler) DeleteFamilyHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的ID")
return
}
if err := h.healthService.DeleteFamilyHistory(userID, uint(id)); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "删除成功", nil)
}
// DeleteAllergyRecord 删除过敏记录
// @Summary 删除过敏记录
// @Tags 健康档案
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param id path int true "记录ID"
// @Success 200 {object} response.Response
// @Router /api/user/allergy-records/{id} [delete]
func (h *HealthHandler) DeleteAllergyRecord(c *gin.Context) {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的ID")
return
}
if err := h.healthService.DeleteAllergyRecord(userID, uint(id)); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "删除成功", nil)
}

172
server/internal/api/handler/product.go

@ -0,0 +1,172 @@
package handler
import (
"strconv"
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type ProductHandler struct {
productService *service.ProductService
}
func NewProductHandler() *ProductHandler {
return &ProductHandler{
productService: service.NewProductService(),
}
}
// GetProducts 获取产品列表
// @Summary 获取产品列表
// @Tags 产品
// @Accept json
// @Produce json
// @Param page query int false "页码,默认1"
// @Param page_size query int false "每页数量,默认20"
// @Success 200 {object} response.Response{data=service.ProductListResponse}
// @Router /api/products [get]
func (h *ProductHandler) GetProducts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.productService.GetProducts(page, pageSize)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
// GetProduct 获取产品详情
// @Summary 获取产品详情
// @Tags 产品
// @Accept json
// @Produce json
// @Param id path int true "产品ID"
// @Success 200 {object} response.Response{data=model.Product}
// @Router /api/products/{id} [get]
func (h *ProductHandler) GetProduct(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.BadRequest(c, "无效的产品ID")
return
}
product, err := h.productService.GetProductByID(uint(id))
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, product)
}
// GetProductsByCategory 按分类获取产品
// @Summary 按分类获取产品
// @Tags 产品
// @Accept json
// @Produce json
// @Param category query string true "分类名称"
// @Success 200 {object} response.Response{data=[]model.Product}
// @Router /api/products/category [get]
func (h *ProductHandler) GetProductsByCategory(c *gin.Context) {
category := c.Query("category")
if category == "" {
response.BadRequest(c, "请指定分类")
return
}
products, err := h.productService.GetProductsByCategory(category)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, products)
}
// GetRecommendedProducts 获取推荐产品(基于用户体质)
// @Summary 获取推荐产品
// @Tags 产品
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=service.ProductRecommendResponse}
// @Router /api/products/recommend [get]
func (h *ProductHandler) GetRecommendedProducts(c *gin.Context) {
userID := middleware.GetUserID(c)
result, err := h.productService.GetRecommendedProducts(userID)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, result)
}
// SearchProducts 搜索产品
// @Summary 搜索产品
// @Tags 产品
// @Accept json
// @Produce json
// @Param keyword query string true "搜索关键词"
// @Success 200 {object} response.Response{data=[]model.Product}
// @Router /api/products/search [get]
func (h *ProductHandler) SearchProducts(c *gin.Context) {
keyword := c.Query("keyword")
if keyword == "" {
response.BadRequest(c, "请输入搜索关键词")
return
}
products, err := h.productService.SearchProducts(keyword)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, products)
}
// SyncPurchase 同步商城购买记录
// @Summary 同步商城购买记录
// @Tags 商城同步
// @Accept json
// @Produce json
// @Param request body service.PurchaseSyncRequest true "购买记录"
// @Success 200 {object} response.Response
// @Router /api/sync/purchase [post]
func (h *ProductHandler) SyncPurchase(c *gin.Context) {
// TODO: 验证同步密钥
var req service.PurchaseSyncRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.productService.SyncPurchase(&req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "同步成功", nil)
}
// GetPurchaseHistory 获取购买历史
// @Summary 获取购买历史
// @Tags 产品
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=[]model.PurchaseHistory}
// @Router /api/user/purchase-history [get]
func (h *ProductHandler) GetPurchaseHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
history, err := h.productService.GetPurchaseHistory(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, history)
}

256
server/internal/api/handler/survey.go

@ -0,0 +1,256 @@
package handler
import (
"health-ai/internal/api/middleware"
"health-ai/internal/service"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
type SurveyHandler struct {
surveyService *service.SurveyService
}
func NewSurveyHandler() *SurveyHandler {
return &SurveyHandler{
surveyService: service.NewSurveyService(),
}
}
// GetStatus 获取调查完成状态
// @Summary 获取调查完成状态
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response{data=service.SurveyStatusResponse}
// @Router /api/survey/status [get]
func (h *SurveyHandler) GetStatus(c *gin.Context) {
userID := middleware.GetUserID(c)
status, err := h.surveyService.GetStatus(userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, status)
}
// SubmitBasicInfo 提交基础信息
// @Summary 提交基础信息
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.BasicInfoRequest true "基础信息"
// @Success 200 {object} response.Response
// @Router /api/survey/basic-info [post]
func (h *SurveyHandler) SubmitBasicInfo(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.BasicInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitBasicInfo(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "基础信息保存成功", nil)
}
// SubmitLifestyle 提交生活习惯
// @Summary 提交生活习惯
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.LifestyleRequest true "生活习惯"
// @Success 200 {object} response.Response
// @Router /api/survey/lifestyle [post]
func (h *SurveyHandler) SubmitLifestyle(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.LifestyleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitLifestyle(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "生活习惯保存成功", nil)
}
// SubmitMedicalHistory 提交病史(单条)
// @Summary 提交病史
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.MedicalHistoryRequest true "病史信息"
// @Success 200 {object} response.Response
// @Router /api/survey/medical-history [post]
func (h *SurveyHandler) SubmitMedicalHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.MedicalHistoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitMedicalHistory(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "病史记录添加成功", nil)
}
// SubmitBatchMedicalHistory 批量提交病史
// @Summary 批量提交病史(覆盖式)
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.BatchMedicalHistoryRequest true "病史列表"
// @Success 200 {object} response.Response
// @Router /api/survey/medical-history/batch [post]
func (h *SurveyHandler) SubmitBatchMedicalHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.BatchMedicalHistoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitBatchMedicalHistory(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "病史记录保存成功", nil)
}
// SubmitFamilyHistory 提交家族病史(单条)
// @Summary 提交家族病史
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.FamilyHistoryRequest true "家族病史"
// @Success 200 {object} response.Response
// @Router /api/survey/family-history [post]
func (h *SurveyHandler) SubmitFamilyHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.FamilyHistoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitFamilyHistory(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "家族病史记录添加成功", nil)
}
// SubmitBatchFamilyHistory 批量提交家族病史
// @Summary 批量提交家族病史(覆盖式)
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.BatchFamilyHistoryRequest true "家族病史列表"
// @Success 200 {object} response.Response
// @Router /api/survey/family-history/batch [post]
func (h *SurveyHandler) SubmitBatchFamilyHistory(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.BatchFamilyHistoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitBatchFamilyHistory(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "家族病史保存成功", nil)
}
// SubmitAllergy 提交过敏信息(单条)
// @Summary 提交过敏信息
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.AllergyRequest true "过敏信息"
// @Success 200 {object} response.Response
// @Router /api/survey/allergy [post]
func (h *SurveyHandler) SubmitAllergy(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.AllergyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitAllergy(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "过敏信息添加成功", nil)
}
// SubmitBatchAllergy 批量提交过敏信息
// @Summary 批量提交过敏信息(覆盖式)
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Param request body service.BatchAllergyRequest true "过敏信息列表"
// @Success 200 {object} response.Response
// @Router /api/survey/allergy/batch [post]
func (h *SurveyHandler) SubmitBatchAllergy(c *gin.Context) {
userID := middleware.GetUserID(c)
var req service.BatchAllergyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.surveyService.SubmitBatchAllergy(userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "过敏信息保存成功", nil)
}
// CompleteSurvey 完成调查
// @Summary 完成健康调查
// @Tags 健康调查
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer Token"
// @Success 200 {object} response.Response
// @Router /api/survey/complete [post]
func (h *SurveyHandler) CompleteSurvey(c *gin.Context) {
userID := middleware.GetUserID(c)
if err := h.surveyService.CompleteSurvey(userID); err != nil {
response.Error(c, 400, err.Error())
return
}
response.SuccessWithMessage(c, "健康调查已完成", nil)
}

72
server/internal/api/middleware/auth.go

@ -0,0 +1,72 @@
package middleware
import (
"strings"
"health-ai/pkg/jwt"
"health-ai/pkg/response"
"github.com/gin-gonic/gin"
)
// AuthRequired JWT认证中间件
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Unauthorized(c, "未提供认证信息")
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.Unauthorized(c, "认证格式错误,请使用 Bearer Token")
c.Abort()
return
}
claims, err := jwt.ParseToken(parts[1])
if err != nil {
response.Unauthorized(c, "Token无效或已过期")
c.Abort()
return
}
// 将用户ID存入上下文
c.Set("userID", claims.UserID)
c.Next()
}
}
// GetUserID 从上下文获取用户ID
func GetUserID(c *gin.Context) uint {
userID, exists := c.Get("userID")
if !exists {
return 0
}
return userID.(uint)
}
// OptionalAuth 可选认证中间件(不强制要求登录)
func OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Next()
return
}
claims, err := jwt.ParseToken(parts[1])
if err == nil {
c.Set("userID", claims.UserID)
}
c.Next()
}
}

136
server/internal/api/router.go

@ -0,0 +1,136 @@
package api
import (
"health-ai/internal/api/handler"
"health-ai/internal/api/middleware"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func SetupRouter(mode string) *gin.Engine {
gin.SetMode(mode)
r := gin.Default()
// 跨域配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
}))
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// API 路由组
apiGroup := r.Group("/api")
{
// =====================
// 认证路由(无需登录)
// =====================
authHandler := handler.NewAuthHandler()
authGroup := apiGroup.Group("/auth")
{
authGroup.POST("/register", authHandler.Register)
authGroup.POST("/login", authHandler.Login)
authGroup.POST("/refresh", authHandler.RefreshToken)
}
// =====================
// 产品路由(部分无需登录)
// =====================
productHandler := handler.NewProductHandler()
productGroup := apiGroup.Group("/products")
{
productGroup.GET("", productHandler.GetProducts)
productGroup.GET("/:id", productHandler.GetProduct)
productGroup.GET("/category", productHandler.GetProductsByCategory)
productGroup.GET("/search", productHandler.SearchProducts)
}
// 商城同步接口(无需用户登录,需要API密钥验证)
apiGroup.POST("/sync/purchase", productHandler.SyncPurchase)
// =====================
// 需要登录的路由
// =====================
authRequired := apiGroup.Group("")
authRequired.Use(middleware.AuthRequired())
{
// =====================
// 用户相关
// =====================
authRequired.GET("/user/profile", authHandler.GetUserInfo)
authRequired.PUT("/user/profile", authHandler.UpdateProfile)
// 健康档案
healthHandler := handler.NewHealthHandler()
authRequired.GET("/user/health-profile", healthHandler.GetHealthProfile)
authRequired.GET("/user/basic-profile", healthHandler.GetBasicProfile)
authRequired.GET("/user/lifestyle", healthHandler.GetLifestyle)
authRequired.GET("/user/medical-history", healthHandler.GetMedicalHistory)
authRequired.DELETE("/user/medical-history/:id", healthHandler.DeleteMedicalHistory)
authRequired.GET("/user/family-history", healthHandler.GetFamilyHistory)
authRequired.DELETE("/user/family-history/:id", healthHandler.DeleteFamilyHistory)
authRequired.GET("/user/allergy-records", healthHandler.GetAllergyRecords)
authRequired.DELETE("/user/allergy-records/:id", healthHandler.DeleteAllergyRecord)
// 购买历史
authRequired.GET("/user/purchase-history", productHandler.GetPurchaseHistory)
// 产品推荐(需要登录获取体质)
authRequired.GET("/products/recommend", productHandler.GetRecommendedProducts)
// =====================
// 健康调查路由
// =====================
surveyHandler := handler.NewSurveyHandler()
surveyGroup := authRequired.Group("/survey")
{
surveyGroup.GET("/status", surveyHandler.GetStatus)
surveyGroup.POST("/basic-info", surveyHandler.SubmitBasicInfo)
surveyGroup.POST("/lifestyle", surveyHandler.SubmitLifestyle)
surveyGroup.POST("/medical-history", surveyHandler.SubmitMedicalHistory)
surveyGroup.POST("/medical-history/batch", surveyHandler.SubmitBatchMedicalHistory)
surveyGroup.POST("/family-history", surveyHandler.SubmitFamilyHistory)
surveyGroup.POST("/family-history/batch", surveyHandler.SubmitBatchFamilyHistory)
surveyGroup.POST("/allergy", surveyHandler.SubmitAllergy)
surveyGroup.POST("/allergy/batch", surveyHandler.SubmitBatchAllergy)
surveyGroup.POST("/complete", surveyHandler.CompleteSurvey)
}
// =====================
// 体质辨识路由
// =====================
constitutionHandler := handler.NewConstitutionHandler()
constitutionGroup := authRequired.Group("/constitution")
{
constitutionGroup.GET("/questions", constitutionHandler.GetQuestions)
constitutionGroup.GET("/questions/grouped", constitutionHandler.GetQuestionsGrouped)
constitutionGroup.POST("/submit", constitutionHandler.SubmitAssessment)
constitutionGroup.GET("/result", constitutionHandler.GetResult)
constitutionGroup.GET("/history", constitutionHandler.GetHistory)
constitutionGroup.GET("/recommendations", constitutionHandler.GetRecommendations)
}
// =====================
// AI对话路由
// =====================
conversationHandler := handler.NewConversationHandler()
convGroup := authRequired.Group("/conversations")
{
convGroup.GET("", conversationHandler.GetConversations)
convGroup.POST("", conversationHandler.CreateConversation)
convGroup.GET("/:id", conversationHandler.GetConversation)
convGroup.DELETE("/:id", conversationHandler.DeleteConversation)
convGroup.POST("/:id/messages", conversationHandler.SendMessage)
convGroup.POST("/:id/messages/stream", conversationHandler.SendMessageStream)
}
}
}
return r
}

79
server/internal/config/config.go

@ -0,0 +1,79 @@
package config
import (
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
AI AIConfig `mapstructure:"ai"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
}
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
SQLite SQLiteConfig `mapstructure:"sqlite"`
Postgres PostgresConfig `mapstructure:"postgres"`
MySQL MySQLConfig `mapstructure:"mysql"`
}
type SQLiteConfig struct {
Path string `mapstructure:"path"`
}
type PostgresConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
}
type MySQLConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
}
type AIConfig struct {
Provider string `mapstructure:"provider"`
MaxHistoryMessages int `mapstructure:"max_history_messages"`
MaxTokens int `mapstructure:"max_tokens"`
OpenAI OpenAIConfig `mapstructure:"openai"`
Aliyun AliyunConfig `mapstructure:"aliyun"`
}
type OpenAIConfig struct {
APIKey string `mapstructure:"api_key"`
BaseURL string `mapstructure:"base_url"`
Model string `mapstructure:"model"`
}
type AliyunConfig struct {
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
}
var AppConfig *Config
func LoadConfig(path string) error {
viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil {
return err
}
AppConfig = &Config{}
return viper.Unmarshal(AppConfig)
}

50
server/internal/database/database.go

@ -0,0 +1,50 @@
package database
import (
"fmt"
"health-ai/internal/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func InitDatabase(cfg *config.DatabaseConfig) error {
var err error
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
switch cfg.Driver {
case "sqlite":
DB, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), gormConfig)
case "postgres":
// TODO: 添加 PostgreSQL 支持
// dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
// cfg.Postgres.Host, cfg.Postgres.Port, cfg.Postgres.User, cfg.Postgres.Password, cfg.Postgres.DBName)
// DB, err = gorm.Open(postgres.Open(dsn), gormConfig)
return fmt.Errorf("postgres driver not implemented yet")
case "mysql":
// TODO: 添加 MySQL 支持
// dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
// cfg.MySQL.User, cfg.MySQL.Password, cfg.MySQL.Host, cfg.MySQL.Port, cfg.MySQL.DBName)
// DB, err = gorm.Open(mysql.Open(dsn), gormConfig)
return fmt.Errorf("mysql driver not implemented yet")
default:
return fmt.Errorf("unsupported database driver: %s", cfg.Driver)
}
return err
}
func AutoMigrate(models ...interface{}) error {
return DB.AutoMigrate(models...)
}
func GetDB() *gorm.DB {
return DB
}

151
server/internal/database/seed.go

@ -0,0 +1,151 @@
package database
import (
"encoding/json"
"log"
"health-ai/internal/model"
"golang.org/x/crypto/bcrypt"
)
// SeedTestUser 创建测试用户
func SeedTestUser() error {
var count int64
DB.Model(&model.User{}).Where("phone = ?", "13800138000").Count(&count)
if count > 0 {
log.Println("Test user already exists")
return nil
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
if err != nil {
return err
}
testUser := &model.User{
Phone: "13800138000",
PasswordHash: string(hashedPassword),
Nickname: "测试用户",
}
if err := DB.Create(testUser).Error; err != nil {
return err
}
log.Println("Test user created: phone=13800138000, password=123456")
return nil
}
// SeedQuestionBank 初始化问卷题库
func SeedQuestionBank() error {
// 检查是否已有数据
var count int64
DB.Model(&model.QuestionBank{}).Count(&count)
if count > 0 {
log.Printf("Question bank already seeded with %d questions", count)
return nil
}
questions := getQuestions()
for _, q := range questions {
if err := DB.Create(&q).Error; err != nil {
return err
}
}
log.Printf("Question bank seeded with %d questions", len(questions))
return nil
}
func getQuestions() []model.QuestionBank {
options, _ := json.Marshal([]string{"没有", "很少", "有时", "经常", "总是"})
optStr := string(options)
return []model.QuestionBank{
// 平和质 (8题)
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您精力充沛吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您能适应外界自然和社会环境的变化吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易失眠吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 8},
// 气虚质 (8题)
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易气短(呼吸短促,接不上气)吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易心慌吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易头晕或站起时晕眩吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您喜欢安静、懒得说话吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您活动量稍大就容易出虚汗吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 8},
// 阳虚质 (7题)
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您手脚发凉吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您胃脘部、背部或腰膝部怕冷吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您穿的衣服总比别人多吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您吃凉东西会感到不舒服或怕吃凉东西吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionYangxu, QuestionText: "您受凉或吃凉的东西后,容易拉肚子吗?", Options: optStr, OrderNum: 7},
// 阴虚质 (8题)
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到手脚心发热吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感觉身体、脸上发热吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您皮肤或口唇干吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您口唇的颜色比一般人红吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您容易便秘或大便干燥吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您面部两颧潮红或偏红吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到眼睛干涩吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到口干咽燥、总想喝水吗?", Options: optStr, OrderNum: 8},
// 痰湿质 (8题)
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到胸闷或腹部胀满吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到身体沉重不轻松或不爽快吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您腹部肥满松软吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您额头部位油脂分泌多吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您上眼睑比别人肿吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您嘴里有黏黏的感觉吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您平时痰多吗?", Options: optStr, OrderNum: 7},
{ConstitutionType: model.ConstitutionTanshi, QuestionText: "您舌苔厚腻或有舌苔厚厚的感觉吗?", Options: optStr, OrderNum: 8},
// 湿热质 (7题)
{ConstitutionType: model.ConstitutionShire, QuestionText: "您面部或鼻部有油腻感或油光发亮吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您脸上容易生痤疮或皮肤容易生疮疖吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您感到口苦或嘴里有异味吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您大便黏滞不爽、有解不尽的感觉吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您小便时尿道有发热感、尿色浓吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您带下色黄(白带颜色发黄)吗?(限女性回答)", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionShire, QuestionText: "您的阴囊部位潮湿吗?(限男性回答)", Options: optStr, OrderNum: 7},
// 血瘀质 (7题)
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您皮肤在不知不觉中会出现青紫瘀斑吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您两颧部有细微红丝吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您身体上有哪里疼痛吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您面色晦暗或容易出现褐斑吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易有黑眼圈吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionXueyu, QuestionText: "您口唇颜色偏暗吗?", Options: optStr, OrderNum: 7},
// 气郁质 (7题)
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您精神紧张、焦虑不安吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您多愁善感、感情脆弱吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您容易感到害怕或受到惊吓吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您胁肋部或乳房胀痛吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您无缘无故叹气吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionQiyu, QuestionText: "您咽喉部有异物感吗?", Options: optStr, OrderNum: 7},
// 特禀质 (7题)
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会打喷嚏吗?", Options: optStr, OrderNum: 1},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会鼻塞、流鼻涕吗?", Options: optStr, OrderNum: 2},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您有因季节变化、温度变化或异味引起的咳嗽吗?", Options: optStr, OrderNum: 3},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您容易过敏吗?", Options: optStr, OrderNum: 4},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤容易起荨麻疹吗?", Options: optStr, OrderNum: 5},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤一抓就红,并出现抓痕吗?", Options: optStr, OrderNum: 6},
{ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤或身上容易出现紫红色瘀点、瘀斑吗?", Options: optStr, OrderNum: 7},
}
}

145
server/internal/model/constitution.go

@ -0,0 +1,145 @@
package model
import (
"time"
"gorm.io/gorm"
)
// ConstitutionAssessment 体质测评记录
type ConstitutionAssessment struct {
gorm.Model
UserID uint `gorm:"index" json:"user_id"`
AssessedAt time.Time `json:"assessed_at"`
Scores string `gorm:"type:text" json:"scores"` // JSON: 各体质得分
PrimaryConstitution string `gorm:"size:20" json:"primary_constitution"` // 主要体质
SecondaryConstitutions string `gorm:"type:text" json:"secondary_constitutions"` // JSON: 次要体质
Recommendations string `gorm:"type:text" json:"recommendations"` // JSON: 调养建议
}
// AssessmentAnswer 问卷答案
type AssessmentAnswer struct {
gorm.Model
AssessmentID uint `gorm:"index" json:"assessment_id"`
QuestionID uint `json:"question_id"`
Score int `json:"score"` // 1-5
}
// QuestionBank 问卷题库
type QuestionBank struct {
gorm.Model
ConstitutionType string `gorm:"size:20;index" json:"constitution_type"` // 体质类型
QuestionText string `gorm:"type:text" json:"question_text"`
Options string `gorm:"type:text" json:"options"` // JSON: 选项
OrderNum int `json:"order_num"`
}
// ConstitutionType 体质类型常量
const (
ConstitutionPinghe = "pinghe" // 平和质
ConstitutionQixu = "qixu" // 气虚质
ConstitutionYangxu = "yangxu" // 阳虚质
ConstitutionYinxu = "yinxu" // 阴虚质
ConstitutionTanshi = "tanshi" // 痰湿质
ConstitutionShire = "shire" // 湿热质
ConstitutionXueyu = "xueyu" // 血瘀质
ConstitutionQiyu = "qiyu" // 气郁质
ConstitutionTebing = "tebing" // 特禀质
)
// ConstitutionNames 体质名称映射
var ConstitutionNames = map[string]string{
ConstitutionPinghe: "平和质",
ConstitutionQixu: "气虚质",
ConstitutionYangxu: "阳虚质",
ConstitutionYinxu: "阴虚质",
ConstitutionTanshi: "痰湿质",
ConstitutionShire: "湿热质",
ConstitutionXueyu: "血瘀质",
ConstitutionQiyu: "气郁质",
ConstitutionTebing: "特禀质",
}
// ConstitutionDescriptions 体质特征描述
var ConstitutionDescriptions = map[string]string{
ConstitutionPinghe: "阴阳气血调和,体态适中,面色红润,精力充沛",
ConstitutionQixu: "元气不足,容易疲劳,气短懒言,易出汗",
ConstitutionYangxu: "阳气不足,畏寒怕冷,手脚冰凉,喜热饮",
ConstitutionYinxu: "阴液亏少,口燥咽干,手足心热,盗汗",
ConstitutionTanshi: "痰湿凝聚,形体肥胖,腹部肥满,痰多",
ConstitutionShire: "湿热内蕴,面垢油光,口苦口干,大便黏滞",
ConstitutionXueyu: "血行不畅,肤色晦暗,易生斑点,健忘",
ConstitutionQiyu: "气机郁滞,情绪低落,多愁善感,胸闷",
ConstitutionTebing: "先天失常,过敏体质,易打喷嚏,皮肤易过敏",
}
// ConstitutionRecommendations 体质调养建议
var ConstitutionRecommendations = map[string]map[string]string{
ConstitutionPinghe: {
"diet": "饮食均衡,不偏食,粗细搭配",
"lifestyle": "起居有常,劳逸结合",
"exercise": "可进行各种运动,量力而行",
"emotion": "保持乐观积极的心态",
},
ConstitutionQixu: {
"diet": "宜食益气健脾食物,如山药、大枣、小米",
"lifestyle": "避免劳累,保证充足睡眠",
"exercise": "宜柔和运动,如太极拳、散步",
"emotion": "避免过度思虑",
},
ConstitutionYangxu: {
"diet": "宜食温阳食物,如羊肉、韭菜、生姜",
"lifestyle": "注意保暖,避免受寒",
"exercise": "宜温和运动,避免大汗",
"emotion": "保持积极乐观",
},
ConstitutionYinxu: {
"diet": "宜食滋阴食物,如百合、银耳、枸杞",
"lifestyle": "避免熬夜,保持环境湿润",
"exercise": "宜静养,避免剧烈运动",
"emotion": "避免急躁易怒",
},
ConstitutionTanshi: {
"diet": "饮食清淡,少食肥甘厚味,宜食薏米、冬瓜",
"lifestyle": "居住环境宜干燥通风",
"exercise": "坚持运动,促进代谢",
"emotion": "保持心情舒畅",
},
ConstitutionShire: {
"diet": "饮食清淡,宜食苦瓜、绿豆、薏米",
"lifestyle": "避免湿热环境,保持皮肤清洁",
"exercise": "适当运动,出汗排湿",
"emotion": "保持平和心态",
},
ConstitutionXueyu: {
"diet": "宜食活血化瘀食物,如山楂、黑木耳",
"lifestyle": "避免久坐,适当活动",
"exercise": "坚持有氧运动,促进血液循环",
"emotion": "保持心情愉快",
},
ConstitutionQiyu: {
"diet": "宜食行气解郁食物,如玫瑰花、佛手",
"lifestyle": "多参加社交活动",
"exercise": "宜户外运动,舒展身心",
"emotion": "学会疏导情绪,培养兴趣爱好",
},
ConstitutionTebing: {
"diet": "避免食用过敏食物,饮食清淡",
"lifestyle": "避免接触过敏原,保持环境清洁",
"exercise": "适度运动,增强体质",
"emotion": "保持心态平和",
},
}
// TableName 指定表名
func (ConstitutionAssessment) TableName() string {
return "constitution_assessments"
}
func (AssessmentAnswer) TableName() string {
return "assessment_answers"
}
func (QuestionBank) TableName() string {
return "question_banks"
}

35
server/internal/model/conversation.go

@ -0,0 +1,35 @@
package model
import "gorm.io/gorm"
// Conversation 对话
type Conversation struct {
gorm.Model
UserID uint `gorm:"index" json:"user_id"`
Title string `gorm:"size:200" json:"title"`
Messages []Message `gorm:"foreignKey:ConversationID" json:"messages,omitempty"`
}
// Message 消息
type Message struct {
gorm.Model
ConversationID uint `gorm:"index" json:"conversation_id"`
Role string `gorm:"size:20" json:"role"` // user, assistant, system
Content string `gorm:"type:text" json:"content"`
}
// MessageRole 消息角色常量
const (
RoleUser = "user"
RoleAssistant = "assistant"
RoleSystem = "system"
)
// TableName 指定表名
func (Conversation) TableName() string {
return "conversations"
}
func (Message) TableName() string {
return "messages"
}

46
server/internal/model/health.go

@ -0,0 +1,46 @@
package model
import "gorm.io/gorm"
// MedicalHistory 既往病史
type MedicalHistory struct {
gorm.Model
HealthProfileID uint `gorm:"index" json:"health_profile_id"`
DiseaseName string `gorm:"size:100" json:"disease_name"`
DiseaseType string `gorm:"size:50" json:"disease_type"` // chronic, surgery, other
DiagnosedDate string `gorm:"size:20" json:"diagnosed_date"`
Status string `gorm:"size:20" json:"status"` // cured, treating, controlled
Notes string `gorm:"type:text" json:"notes"`
}
// FamilyHistory 家族病史
type FamilyHistory struct {
gorm.Model
HealthProfileID uint `gorm:"index" json:"health_profile_id"`
Relation string `gorm:"size:20" json:"relation"` // father, mother, grandparent
DiseaseName string `gorm:"size:100" json:"disease_name"`
Notes string `gorm:"type:text" json:"notes"`
}
// AllergyRecord 过敏记录
type AllergyRecord struct {
gorm.Model
HealthProfileID uint `gorm:"index" json:"health_profile_id"`
AllergyType string `gorm:"size:20" json:"allergy_type"` // drug, food, other
Allergen string `gorm:"size:100" json:"allergen"`
Severity string `gorm:"size:20" json:"severity"` // mild, moderate, severe
ReactionDesc string `gorm:"type:text" json:"reaction_desc"`
}
// TableName 指定表名
func (MedicalHistory) TableName() string {
return "medical_histories"
}
func (FamilyHistory) TableName() string {
return "family_histories"
}
func (AllergyRecord) TableName() string {
return "allergy_records"
}

31
server/internal/model/models.go

@ -0,0 +1,31 @@
package model
// AllModels 返回所有需要迁移的模型
func AllModels() []interface{} {
return []interface{}{
// 用户相关
&User{},
&HealthProfile{},
&LifestyleInfo{},
// 健康相关
&MedicalHistory{},
&FamilyHistory{},
&AllergyRecord{},
// 体质相关
&ConstitutionAssessment{},
&AssessmentAnswer{},
&QuestionBank{},
// 对话相关
&Conversation{},
&Message{},
// 产品相关
&Product{},
&ConstitutionProduct{},
&SymptomProduct{},
&PurchaseHistory{},
}
}

66
server/internal/model/product.go

@ -0,0 +1,66 @@
package model
import (
"time"
"gorm.io/gorm"
)
// Product 保健品表
type Product struct {
gorm.Model
Name string `gorm:"size:100" json:"name"`
Category string `gorm:"size:50;index" json:"category"`
Description string `gorm:"type:text" json:"description"`
Efficacy string `gorm:"type:text" json:"efficacy"` // 功效说明
Suitable string `gorm:"type:text" json:"suitable"` // 适用人群/体质
Price float64 `json:"price"`
ImageURL string `gorm:"size:255" json:"image_url"`
MallURL string `gorm:"size:255" json:"mall_url"` // 商城链接
IsActive bool `gorm:"default:true" json:"is_active"`
}
// ConstitutionProduct 体质-产品关联表
type ConstitutionProduct struct {
gorm.Model
ConstitutionType string `gorm:"size:20;index" json:"constitution_type"`
ProductID uint `gorm:"index" json:"product_id"`
Priority int `json:"priority"` // 推荐优先级
Reason string `gorm:"size:200" json:"reason"` // 推荐理由
}
// SymptomProduct 症状-产品关联表
type SymptomProduct struct {
gorm.Model
Keyword string `gorm:"size:50;index" json:"keyword"` // 症状关键词
ProductID uint `gorm:"index" json:"product_id"`
Priority int `json:"priority"`
}
// PurchaseHistory 购买历史表(商城同步)
type PurchaseHistory struct {
gorm.Model
UserID uint `gorm:"index" json:"user_id"`
OrderNo string `gorm:"size:50" json:"order_no"` // 商城订单号
ProductID uint `json:"product_id"`
ProductName string `gorm:"size:100" json:"product_name"`
PurchasedAt time.Time `json:"purchased_at"`
Source string `gorm:"size:20" json:"source"` // mall=保健品商城
}
// TableName 指定表名
func (Product) TableName() string {
return "products"
}
func (ConstitutionProduct) TableName() string {
return "constitution_products"
}
func (SymptomProduct) TableName() string {
return "symptom_products"
}
func (PurchaseHistory) TableName() string {
return "purchase_histories"
}

64
server/internal/model/user.go

@ -0,0 +1,64 @@
package model
import (
"time"
"gorm.io/gorm"
)
// User 用户表
type User struct {
gorm.Model
Phone string `gorm:"uniqueIndex;size:20" json:"phone"`
Email string `gorm:"uniqueIndex;size:100" json:"email"`
PasswordHash string `gorm:"size:255" json:"-"`
Nickname string `gorm:"size:50" json:"nickname"`
Avatar string `gorm:"size:255" json:"avatar"`
SurveyCompleted bool `gorm:"default:false" json:"survey_completed"`
}
// HealthProfile 健康档案
type HealthProfile struct {
gorm.Model
UserID uint `gorm:"uniqueIndex" json:"user_id"`
Name string `gorm:"size:50" json:"name"`
BirthDate *time.Time `json:"birth_date"`
Gender string `gorm:"size:10" json:"gender"` // male, female
Height float64 `json:"height"` // cm
Weight float64 `json:"weight"` // kg
BMI float64 `json:"bmi"`
BloodType string `gorm:"size:10" json:"blood_type"` // A, B, AB, O
Occupation string `gorm:"size:50" json:"occupation"`
MaritalStatus string `gorm:"size:20" json:"marital_status"` // single, married, divorced
Region string `gorm:"size:100" json:"region"`
}
// LifestyleInfo 生活习惯
type LifestyleInfo struct {
gorm.Model
UserID uint `gorm:"uniqueIndex" json:"user_id"`
SleepTime string `gorm:"size:10" json:"sleep_time"` // HH:MM
WakeTime string `gorm:"size:10" json:"wake_time"` // HH:MM
SleepQuality string `gorm:"size:20" json:"sleep_quality"` // good, normal, poor
MealRegularity string `gorm:"size:20" json:"meal_regularity"` // regular, irregular
DietPreference string `gorm:"size:50" json:"diet_preference"` // 偏好
DailyWaterML int `json:"daily_water_ml"` // 每日饮水量 ml
ExerciseFrequency string `gorm:"size:20" json:"exercise_frequency"` // never, sometimes, often, daily
ExerciseType string `gorm:"size:100" json:"exercise_type"`
ExerciseDurationMin int `json:"exercise_duration_min"` // 每次运动时长
IsSmoker bool `json:"is_smoker"`
AlcoholFrequency string `gorm:"size:20" json:"alcohol_frequency"` // never, sometimes, often
}
// TableName 指定表名
func (User) TableName() string {
return "users"
}
func (HealthProfile) TableName() string {
return "health_profiles"
}
func (LifestyleInfo) TableName() string {
return "lifestyle_infos"
}

71
server/internal/repository/impl/constitution.go

@ -0,0 +1,71 @@
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type ConstitutionRepository struct{}
func NewConstitutionRepository() *ConstitutionRepository {
return &ConstitutionRepository{}
}
// GetQuestions 获取所有问卷题目
func (r *ConstitutionRepository) GetQuestions() ([]model.QuestionBank, error) {
var questions []model.QuestionBank
err := database.DB.Order("constitution_type, order_num").Find(&questions).Error
return questions, err
}
// GetQuestionsByType 获取指定体质类型的问题
func (r *ConstitutionRepository) GetQuestionsByType(constitutionType string) ([]model.QuestionBank, error) {
var questions []model.QuestionBank
err := database.DB.Where("constitution_type = ?", constitutionType).Order("order_num").Find(&questions).Error
return questions, err
}
// CreateAssessment 创建体质测评记录
func (r *ConstitutionRepository) CreateAssessment(assessment *model.ConstitutionAssessment) error {
return database.DB.Create(assessment).Error
}
// GetLatestAssessment 获取用户最新的体质测评结果
func (r *ConstitutionRepository) GetLatestAssessment(userID uint) (*model.ConstitutionAssessment, error) {
var assessment model.ConstitutionAssessment
err := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC").First(&assessment).Error
return &assessment, err
}
// GetAssessmentHistory 获取用户的体质测评历史
func (r *ConstitutionRepository) GetAssessmentHistory(userID uint, limit int) ([]model.ConstitutionAssessment, error) {
var assessments []model.ConstitutionAssessment
query := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&assessments).Error
return assessments, err
}
// GetAssessmentByID 根据ID获取测评记录
func (r *ConstitutionRepository) GetAssessmentByID(id uint) (*model.ConstitutionAssessment, error) {
var assessment model.ConstitutionAssessment
err := database.DB.First(&assessment, id).Error
return &assessment, err
}
// CreateAnswers 批量创建问卷答案
func (r *ConstitutionRepository) CreateAnswers(answers []model.AssessmentAnswer) error {
if len(answers) == 0 {
return nil
}
return database.DB.Create(&answers).Error
}
// GetAnswersByAssessmentID 获取测评的所有答案
func (r *ConstitutionRepository) GetAnswersByAssessmentID(assessmentID uint) ([]model.AssessmentAnswer, error) {
var answers []model.AssessmentAnswer
err := database.DB.Where("assessment_id = ?", assessmentID).Find(&answers).Error
return answers, err
}

78
server/internal/repository/impl/conversation.go

@ -0,0 +1,78 @@
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type ConversationRepository struct{}
func NewConversationRepository() *ConversationRepository {
return &ConversationRepository{}
}
// Create 创建对话
func (r *ConversationRepository) Create(conv *model.Conversation) error {
return database.DB.Create(conv).Error
}
// GetByID 根据ID获取对话(含消息)
func (r *ConversationRepository) GetByID(id uint) (*model.Conversation, error) {
var conv model.Conversation
err := database.DB.Preload("Messages").First(&conv, id).Error
return &conv, err
}
// GetByUserID 获取用户的所有对话
func (r *ConversationRepository) GetByUserID(userID uint) ([]model.Conversation, error) {
var convs []model.Conversation
err := database.DB.Where("user_id = ?", userID).Order("updated_at DESC").Find(&convs).Error
return convs, err
}
// Delete 删除对话(同时删除消息)
func (r *ConversationRepository) Delete(id uint) error {
// 先删除消息
database.DB.Where("conversation_id = ?", id).Delete(&model.Message{})
return database.DB.Delete(&model.Conversation{}, id).Error
}
// AddMessage 添加消息
func (r *ConversationRepository) AddMessage(msg *model.Message) error {
// 同时更新对话的更新时间
database.DB.Model(&model.Conversation{}).Where("id = ?", msg.ConversationID).Update("updated_at", msg.CreatedAt)
return database.DB.Create(msg).Error
}
// GetMessages 获取对话的消息
func (r *ConversationRepository) GetMessages(convID uint) ([]model.Message, error) {
var messages []model.Message
err := database.DB.Where("conversation_id = ?", convID).Order("created_at ASC").Find(&messages).Error
return messages, err
}
// GetRecentMessages 获取对话最近的N条消息
func (r *ConversationRepository) GetRecentMessages(convID uint, limit int) ([]model.Message, error) {
var messages []model.Message
err := database.DB.Where("conversation_id = ?", convID).Order("created_at DESC").Limit(limit).Find(&messages).Error
if err != nil {
return nil, err
}
// 反转顺序,使消息按时间正序排列
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
return messages, nil
}
// UpdateTitle 更新对话标题
func (r *ConversationRepository) UpdateTitle(id uint, title string) error {
return database.DB.Model(&model.Conversation{}).Where("id = ?", id).Update("title", title).Error
}
// CheckOwnership 检查对话是否属于用户
func (r *ConversationRepository) CheckOwnership(convID, userID uint) bool {
var count int64
database.DB.Model(&model.Conversation{}).Where("id = ? AND user_id = ?", convID, userID).Count(&count)
return count > 0
}

127
server/internal/repository/impl/health.go

@ -0,0 +1,127 @@
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type HealthRepository struct{}
func NewHealthRepository() *HealthRepository {
return &HealthRepository{}
}
// ================= HealthProfile =================
func (r *HealthRepository) CreateProfile(profile *model.HealthProfile) error {
return database.DB.Create(profile).Error
}
func (r *HealthRepository) GetProfileByUserID(userID uint) (*model.HealthProfile, error) {
var profile model.HealthProfile
err := database.DB.Where("user_id = ?", userID).First(&profile).Error
return &profile, err
}
func (r *HealthRepository) UpdateProfile(profile *model.HealthProfile) error {
return database.DB.Save(profile).Error
}
// ================= LifestyleInfo =================
func (r *HealthRepository) CreateLifestyle(lifestyle *model.LifestyleInfo) error {
return database.DB.Create(lifestyle).Error
}
func (r *HealthRepository) GetLifestyleByUserID(userID uint) (*model.LifestyleInfo, error) {
var lifestyle model.LifestyleInfo
err := database.DB.Where("user_id = ?", userID).First(&lifestyle).Error
return &lifestyle, err
}
func (r *HealthRepository) UpdateLifestyle(lifestyle *model.LifestyleInfo) error {
return database.DB.Save(lifestyle).Error
}
// ================= MedicalHistory =================
func (r *HealthRepository) CreateMedicalHistory(history *model.MedicalHistory) error {
return database.DB.Create(history).Error
}
func (r *HealthRepository) GetMedicalHistories(profileID uint) ([]model.MedicalHistory, error) {
var histories []model.MedicalHistory
err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error
return histories, err
}
func (r *HealthRepository) DeleteMedicalHistory(id uint) error {
return database.DB.Delete(&model.MedicalHistory{}, id).Error
}
func (r *HealthRepository) BatchCreateMedicalHistories(histories []model.MedicalHistory) error {
if len(histories) == 0 {
return nil
}
return database.DB.Create(&histories).Error
}
// ================= FamilyHistory =================
func (r *HealthRepository) CreateFamilyHistory(history *model.FamilyHistory) error {
return database.DB.Create(history).Error
}
func (r *HealthRepository) GetFamilyHistories(profileID uint) ([]model.FamilyHistory, error) {
var histories []model.FamilyHistory
err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error
return histories, err
}
func (r *HealthRepository) DeleteFamilyHistory(id uint) error {
return database.DB.Delete(&model.FamilyHistory{}, id).Error
}
func (r *HealthRepository) BatchCreateFamilyHistories(histories []model.FamilyHistory) error {
if len(histories) == 0 {
return nil
}
return database.DB.Create(&histories).Error
}
// ================= AllergyRecord =================
func (r *HealthRepository) CreateAllergyRecord(record *model.AllergyRecord) error {
return database.DB.Create(record).Error
}
func (r *HealthRepository) GetAllergyRecords(profileID uint) ([]model.AllergyRecord, error) {
var records []model.AllergyRecord
err := database.DB.Where("health_profile_id = ?", profileID).Find(&records).Error
return records, err
}
func (r *HealthRepository) DeleteAllergyRecord(id uint) error {
return database.DB.Delete(&model.AllergyRecord{}, id).Error
}
func (r *HealthRepository) BatchCreateAllergyRecords(records []model.AllergyRecord) error {
if len(records) == 0 {
return nil
}
return database.DB.Create(&records).Error
}
// ================= 清除旧数据方法 =================
func (r *HealthRepository) ClearMedicalHistories(profileID uint) error {
return database.DB.Where("health_profile_id = ?", profileID).Delete(&model.MedicalHistory{}).Error
}
func (r *HealthRepository) ClearFamilyHistories(profileID uint) error {
return database.DB.Where("health_profile_id = ?", profileID).Delete(&model.FamilyHistory{}).Error
}
func (r *HealthRepository) ClearAllergyRecords(profileID uint) error {
return database.DB.Where("health_profile_id = ?", profileID).Delete(&model.AllergyRecord{}).Error
}

99
server/internal/repository/impl/product.go

@ -0,0 +1,99 @@
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type ProductRepository struct{}
func NewProductRepository() *ProductRepository {
return &ProductRepository{}
}
// GetByID 根据ID获取产品
func (r *ProductRepository) GetByID(id uint) (*model.Product, error) {
var product model.Product
err := database.DB.Where("is_active = ?", true).First(&product, id).Error
return &product, err
}
// GetAll 获取所有产品(分页)
func (r *ProductRepository) GetAll(page, pageSize int) ([]model.Product, int64, error) {
var products []model.Product
var total int64
db := database.DB.Model(&model.Product{}).Where("is_active = ?", true)
db.Count(&total)
offset := (page - 1) * pageSize
err := db.Offset(offset).Limit(pageSize).Find(&products).Error
return products, total, err
}
// GetByCategory 按分类获取产品
func (r *ProductRepository) GetByCategory(category string) ([]model.Product, error) {
var products []model.Product
err := database.DB.Where("category = ? AND is_active = ?", category, true).Find(&products).Error
return products, err
}
// GetByConstitution 根据体质获取推荐产品
func (r *ProductRepository) GetByConstitution(constitutionType string) ([]model.Product, error) {
var products []model.Product
err := database.DB.Joins("JOIN constitution_products ON products.id = constitution_products.product_id").
Where("constitution_products.constitution_type = ? AND products.is_active = ?", constitutionType, true).
Order("constitution_products.priority ASC").
Find(&products).Error
return products, err
}
// SearchByKeyword 根据症状关键词搜索产品
func (r *ProductRepository) SearchByKeyword(keyword string) ([]model.Product, error) {
var products []model.Product
// 先从症状-产品关联表搜索
subQuery := database.DB.Table("symptom_products").
Select("product_id").
Where("keyword LIKE ?", "%"+keyword+"%")
err := database.DB.Where("id IN (?) AND is_active = ?", subQuery, true).Find(&products).Error
if err != nil {
return nil, err
}
// 如果没找到,从产品名称和描述中搜索
if len(products) == 0 {
err = database.DB.Where("(name LIKE ? OR description LIKE ? OR efficacy LIKE ?) AND is_active = ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", true).Find(&products).Error
}
return products, err
}
// GetConstitutionProducts 获取体质-产品关联
func (r *ProductRepository) GetConstitutionProducts(constitutionType string) ([]model.ConstitutionProduct, error) {
var cps []model.ConstitutionProduct
err := database.DB.Where("constitution_type = ?", constitutionType).Order("priority ASC").Find(&cps).Error
return cps, err
}
// CreatePurchaseHistory 创建购买历史
func (r *ProductRepository) CreatePurchaseHistory(history *model.PurchaseHistory) error {
return database.DB.Create(history).Error
}
// GetPurchaseHistory 获取用户购买历史
func (r *ProductRepository) GetPurchaseHistory(userID uint) ([]model.PurchaseHistory, error) {
var histories []model.PurchaseHistory
err := database.DB.Where("user_id = ?", userID).Order("purchased_at DESC").Find(&histories).Error
return histories, err
}
// BatchCreatePurchaseHistory 批量创建购买历史
func (r *ProductRepository) BatchCreatePurchaseHistory(histories []model.PurchaseHistory) error {
if len(histories) == 0 {
return nil
}
return database.DB.Create(&histories).Error
}

42
server/internal/repository/impl/user.go

@ -0,0 +1,42 @@
package impl
import (
"health-ai/internal/database"
"health-ai/internal/model"
)
type UserRepositoryImpl struct{}
func NewUserRepository() *UserRepositoryImpl {
return &UserRepositoryImpl{}
}
func (r *UserRepositoryImpl) Create(user *model.User) error {
return database.DB.Create(user).Error
}
func (r *UserRepositoryImpl) GetByID(id uint) (*model.User, error) {
var user model.User
err := database.DB.First(&user, id).Error
return &user, err
}
func (r *UserRepositoryImpl) GetByPhone(phone string) (*model.User, error) {
var user model.User
err := database.DB.Where("phone = ?", phone).First(&user).Error
return &user, err
}
func (r *UserRepositoryImpl) GetByEmail(email string) (*model.User, error) {
var user model.User
err := database.DB.Where("email = ?", email).First(&user).Error
return &user, err
}
func (r *UserRepositoryImpl) Update(user *model.User) error {
return database.DB.Save(user).Error
}
func (r *UserRepositoryImpl) UpdateSurveyStatus(userID uint, completed bool) error {
return database.DB.Model(&model.User{}).Where("id = ?", userID).Update("survey_completed", completed).Error
}

66
server/internal/repository/interface.go

@ -0,0 +1,66 @@
package repository
import "health-ai/internal/model"
// UserRepository 用户数据访问接口
type UserRepository interface {
Create(user *model.User) error
GetByID(id uint) (*model.User, error)
GetByPhone(phone string) (*model.User, error)
GetByEmail(email string) (*model.User, error)
Update(user *model.User) error
UpdateSurveyStatus(userID uint, completed bool) error
}
// HealthProfileRepository 健康档案数据访问接口
type HealthProfileRepository interface {
Create(profile *model.HealthProfile) error
GetByUserID(userID uint) (*model.HealthProfile, error)
Update(profile *model.HealthProfile) error
Upsert(profile *model.HealthProfile) error
}
// LifestyleRepository 生活习惯数据访问接口
type LifestyleRepository interface {
Create(lifestyle *model.LifestyleInfo) error
GetByUserID(userID uint) (*model.LifestyleInfo, error)
Update(lifestyle *model.LifestyleInfo) error
Upsert(lifestyle *model.LifestyleInfo) error
}
// MedicalHistoryRepository 病史数据访问接口
type MedicalHistoryRepository interface {
Create(history *model.MedicalHistory) error
GetByHealthProfileID(healthProfileID uint) ([]model.MedicalHistory, error)
Update(history *model.MedicalHistory) error
Delete(id uint) error
BatchCreate(histories []model.MedicalHistory) error
}
// ConstitutionRepository 体质测评数据访问接口
type ConstitutionRepository interface {
CreateAssessment(assessment *model.ConstitutionAssessment) error
GetLatestByUserID(userID uint) (*model.ConstitutionAssessment, error)
GetHistoryByUserID(userID uint, limit int) ([]model.ConstitutionAssessment, error)
GetQuestions() ([]model.QuestionBank, error)
CreateAnswers(answers []model.AssessmentAnswer) error
}
// ConversationRepository 对话数据访问接口
type ConversationRepository interface {
Create(conversation *model.Conversation) error
GetByID(id uint) (*model.Conversation, error)
GetByUserID(userID uint) ([]model.Conversation, error)
Delete(id uint) error
AddMessage(message *model.Message) error
GetMessages(conversationID uint, limit int) ([]model.Message, error)
}
// ProductRepository 产品数据访问接口
type ProductRepository interface {
GetByID(id uint) (*model.Product, error)
GetByCategory(category string) ([]model.Product, error)
GetByConstitution(constitutionType string) ([]model.Product, error)
SearchByKeyword(keyword string) ([]model.Product, error)
GetAll(page, pageSize int) ([]model.Product, int64, error)
}

165
server/internal/service/ai/aliyun.go

@ -0,0 +1,165 @@
package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
const AliyunBaseURL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
type AliyunClient struct {
apiKey string
model string
}
func NewAliyunClient(cfg *Config) *AliyunClient {
model := cfg.Model
if model == "" {
model = "qwen-turbo"
}
return &AliyunClient{
apiKey: cfg.APIKey,
model: model,
}
}
type aliyunRequest struct {
Model string `json:"model"`
Input struct {
Messages []Message `json:"messages"`
} `json:"input"`
Parameters struct {
ResultFormat string `json:"result_format"`
MaxTokens int `json:"max_tokens,omitempty"`
} `json:"parameters"`
}
type aliyunResponse struct {
Output struct {
Text string `json:"text"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
} `json:"output"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
Code string `json:"code"`
Message string `json:"message"`
}
func (c *AliyunClient) Chat(ctx context.Context, messages []Message) (string, error) {
if c.apiKey == "" {
return "", fmt.Errorf("阿里云通义千问 API Key 未配置,请在 config.yaml 中设置 ai.aliyun.api_key")
}
reqBody := aliyunRequest{
Model: c.model,
}
reqBody.Input.Messages = messages
reqBody.Parameters.ResultFormat = "message"
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("调用AI服务失败: %v", err)
}
defer resp.Body.Close()
var result aliyunResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("解析AI响应失败: %v", err)
}
if result.Code != "" {
return "", fmt.Errorf("AI服务错误: %s - %s", result.Code, result.Message)
}
// 兼容两种返回格式
if len(result.Output.Choices) > 0 {
return result.Output.Choices[0].Message.Content, nil
}
if result.Output.Text != "" {
return result.Output.Text, nil
}
return "", fmt.Errorf("AI未返回有效响应")
}
func (c *AliyunClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error {
if c.apiKey == "" {
return fmt.Errorf("阿里云通义千问 API Key 未配置")
}
reqBody := aliyunRequest{
Model: c.model,
}
reqBody.Input.Messages = messages
reqBody.Parameters.ResultFormat = "message"
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("X-DashScope-SSE", "enable") // 启用流式输出
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 解析 SSE 流
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return err
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data:") {
data := strings.TrimPrefix(line, "data:")
data = strings.TrimSpace(data)
if data == "[DONE]" {
break
}
var streamResp aliyunResponse
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
continue
}
if len(streamResp.Output.Choices) > 0 {
content := streamResp.Output.Choices[0].Message.Content
writer.Write([]byte(content))
}
}
}
return nil
}

26
server/internal/service/ai/client.go

@ -0,0 +1,26 @@
package ai
import (
"context"
"io"
)
// AIClient AI 客户端接口
type AIClient interface {
Chat(ctx context.Context, messages []Message) (string, error)
ChatStream(ctx context.Context, messages []Message, writer io.Writer) error
}
// Message 对话消息
type Message struct {
Role string `json:"role"` // system, user, assistant
Content string `json:"content"`
}
// Config AI 配置
type Config struct {
Provider string
APIKey string
BaseURL string
Model string
}

22
server/internal/service/ai/factory.go

@ -0,0 +1,22 @@
package ai
import "health-ai/internal/config"
// NewAIClient 根据配置创建 AI 客户端
func NewAIClient(cfg *config.AIConfig) AIClient {
switch cfg.Provider {
case "aliyun":
return NewAliyunClient(&Config{
APIKey: cfg.Aliyun.APIKey,
Model: cfg.Aliyun.Model,
})
case "openai":
fallthrough
default:
return NewOpenAIClient(&Config{
APIKey: cfg.OpenAI.APIKey,
BaseURL: cfg.OpenAI.BaseURL,
Model: cfg.OpenAI.Model,
})
}
}

156
server/internal/service/ai/openai.go

@ -0,0 +1,156 @@
package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type OpenAIClient struct {
apiKey string
baseURL string
model string
}
func NewOpenAIClient(cfg *Config) *OpenAIClient {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
model := cfg.Model
if model == "" {
model = "gpt-3.5-turbo"
}
return &OpenAIClient{
apiKey: cfg.APIKey,
baseURL: baseURL,
model: model,
}
}
type openAIRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type openAIResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (c *OpenAIClient) Chat(ctx context.Context, messages []Message) (string, error) {
if c.apiKey == "" {
return "", fmt.Errorf("OpenAI API Key 未配置,请在 config.yaml 中设置 ai.openai.api_key")
}
reqBody := openAIRequest{
Model: c.model,
Messages: messages,
Stream: false,
}
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("调用AI服务失败: %v", err)
}
defer resp.Body.Close()
var result openAIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("解析AI响应失败: %v", err)
}
if result.Error != nil {
return "", fmt.Errorf("AI服务错误: %s", result.Error.Message)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("AI未返回有效响应")
}
return result.Choices[0].Message.Content, nil
}
func (c *OpenAIClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error {
if c.apiKey == "" {
return fmt.Errorf("OpenAI API Key 未配置")
}
reqBody := openAIRequest{
Model: c.model,
Messages: messages,
Stream: true,
}
body, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 解析 SSE 流
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return err
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data:") {
data := strings.TrimPrefix(line, "data:")
data = strings.TrimSpace(data)
if data == "[DONE]" {
break
}
var streamResp openAIResponse
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
continue
}
if len(streamResp.Choices) > 0 {
content := streamResp.Choices[0].Delta.Content
writer.Write([]byte(content))
}
}
}
return nil
}

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

Loading…
Cancel
Save