@ -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 |
|||
@ -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/` 目录下的开发文档进行后端对接。 |
|||
@ -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 调用(后端对接)| |
|||
|
|||
--- |
|||
|
|||
## 后续扩展(暂不开发) |
|||
|
|||
| 功能 | 类型 | 备注 | |
|||
|------|------|------| |
|||
| 会员系统 | 积分制 | 消费/签到积分兑换权益 | |
|||
@ -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` |
|||
@ -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` |
|||
@ -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]' |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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 原型开发所有页面文档创建完成! |
|||
|
|||
可以开始第四阶段:后端开发。 |
|||
@ -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` |
|||
@ -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` |
|||
@ -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 |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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. **发布**:提交到应用商店审核 |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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` |
|||
@ -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 分钟 |
|||
|
|||
--- |
|||
|
|||
## 完成 |
|||
|
|||
恭喜!项目开发完成! |
|||
|
|||
可选下一步: |
|||
- 部署上线 |
|||
- 性能优化 |
|||
- 功能迭代 |
|||
@ -0,0 +1,4 @@ |
|||
# Agents 开发规范 |
|||
|
|||
- 涉及到任何代码修改,记得更新此文档和设计文档 |
|||
- |
|||
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 297 KiB |
@ -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问询助手 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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 或联系后端开发团队。 |
|||
@ -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 |
|||
) |
|||
@ -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= |
|||
@ -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"` |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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", "") |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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}, |
|||
} |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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{}, |
|||
} |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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, |
|||
}) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||