commit dc9da9078abbb7932de1906e672053ad98f42b48 Author: dark Date: Sun Feb 1 17:57:11 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3024208 --- /dev/null +++ b/.gitignore @@ -0,0 +1,100 @@ +# 依赖目录 +node_modules/ +.pnp/ +.pnp.js + +# 构建输出 +dist/ +build/ +.output/ +.nuxt/ + +# Expo / React Native +.expo/ +.expo-shared/ +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ +android/ +ios/ + +# Vue.js +.vite/ + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +vendor/ + +# 环境变量 +.env +.env.local +.env.*.local +.env.development +.env.production +*.env + +# 日志文件 +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# 编辑器/IDE +.idea/ +.vscode/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +*.swp +*.swo +.project +.classpath +.settings/ + +# 系统文件 +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# 测试覆盖率 +coverage/ +.nyc_output/ + +# 缓存 +.cache/ +.parcel-cache/ +.eslintcache +.stylelintcache +*.tsbuildinfo + +# 临时文件 +tmp/ +temp/ +*.tmp +*.temp + +# 包管理锁文件 (可选,根据团队约定) +# package-lock.json +# yarn.lock +# pnpm-lock.yaml + +# 本地配置 +local.properties diff --git a/README.md b/README.md new file mode 100644 index 0000000..be389d2 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# 健康AI助手 - 原型项目 + +基于中医体质辨识理论的智能健康咨询平台原型。 + +## 快速启动 + +**Windows 用户:** +双击项目根目录的 `start.bat` 文件,选择要启动的服务。 + +或者分别启动: +```bash +# 启动 Web 原型 +cd web && npm run dev + +# 启动 APP 原型 +cd app && npx expo start +``` + +## 测试账号 + +- **手机号**: `13800138000` +- **验证码**: `123456` + +## 项目结构 + +``` +healthApps/ +├── web/ # Web 原型 (Vue 3 + Vite) +├── app/ # APP 原型 (React Native + Expo) +├── server/ # 后端服务 (Go + Gin) +├── scripts/ # 启动脚本 +│ ├── start-web.bat +│ ├── start-app.bat +│ └── start-all.bat +├── start.bat # 主启动入口 +├── design.md # 项目设计文档 +└── TODOS/ # 开发任务文档 +``` + +## 技术栈 + +| 项目 | 技术 | 端口 | +|-----|------|-----| +| Web | Vue 3 + TypeScript + Element Plus | 5173 | +| APP | React Native + Expo + Paper | 8081 | +| 后端 | Go + Gin + SQLite | 8080 | + +## 功能模块 + +- **用户登录** - 手机号验证码登录(模拟) +- **首页** - 体质概览、快捷入口、健康提示 +- **体质测试** - 20道问卷,本地计算体质类型 +- **AI问答** - 模拟AI健康咨询对话 +- **个人中心** - 用户信息、健康档案 + +## 原型说明 + +当前版本为原型演示版,使用本地模拟数据: +- 登录验证:模拟验证 +- 体质测试:本地计算 +- AI对话:关键词匹配模拟回复 +- 数据存储:localStorage / AsyncStorage + +## 后续开发 + +参考 `TODOS/` 目录下的开发文档进行后端对接。 diff --git a/TODOS/00-项目总览.md b/TODOS/00-项目总览.md new file mode 100644 index 0000000..f6fcb7e --- /dev/null +++ b/TODOS/00-项目总览.md @@ -0,0 +1,132 @@ +# 健康AI问询助手 - 开发总览 + +## 项目信息 + +- **项目名称**: 健康AI问询助手 +- **技术栈**: Vue 3 + React Native + Go (Gin) + SQLite +- **开发模式**: 前端原型优先(使用模拟数据) +- **预计模块**: 5大阶段,25个开发任务 +- **关联项目**: 保健品商城(外部系统) + +--- + +## 开发阶段总览 + +> **开发策略**:先完成前端原型(APP + Web),使用模拟数据实现完整交互演示,后续再开发后端并对接。 + +### 第一阶段:环境搭建 +| 序号 | 任务 | 文档 | 状态 | +|------|------|------|------| +| 1.1 | APP React Native 环境搭建 | `01-环境搭建/01-APP-ReactNative环境搭建.md` | ⬜ 待审议 | +| 1.2 | Web 前端 Vue 环境搭建 | `01-环境搭建/02-Web前端Vue环境搭建.md` | ⬜ 待审议 | + +### 第二阶段:APP 原型开发(模拟数据) +| 序号 | 任务 | 文档 | 状态 | +|------|------|------|------| +| 2.1 | 项目初始化 + 模拟数据服务 | `02-APP原型开发/01-项目初始化和模拟数据.md` | ⬜ 待审议 | +| 2.2 | 导航和底部Tab设计 | `02-APP原型开发/02-导航和布局设计.md` | ⬜ 待审议 | +| 2.3 | 登录页面原型 | `02-APP原型开发/03-登录页面.md` | ⬜ 待审议 | +| 2.4 | 首页原型 | `02-APP原型开发/04-首页.md` | ⬜ 待审议 | +| 2.5 | 体质辨识页面原型 | `02-APP原型开发/05-体质辨识页面.md` | ⬜ 待审议 | +| 2.6 | AI对话页面原型 | `02-APP原型开发/06-AI对话页面.md` | ⬜ 待审议 | +| 2.7 | 个人中心页面原型 | `02-APP原型开发/07-个人中心页面.md` | ⬜ 待审议 | + +### 第三阶段:Web 原型开发(模拟数据) +| 序号 | 任务 | 文档 | 状态 | +|------|------|------|------| +| 3.1 | 项目初始化 + 模拟数据服务 | `03-Web原型开发/01-项目初始化和模拟数据.md` | ⬜ 待审议 | +| 3.2 | 路由和布局设计 | `03-Web原型开发/02-路由和布局设计.md` | ⬜ 待审议 | +| 3.3 | 登录页面原型 | `03-Web原型开发/03-登录页面.md` | ⬜ 待审议 | +| 3.4 | 首页原型 | `03-Web原型开发/04-首页.md` | ⬜ 待审议 | +| 3.5 | 体质辨识页面原型 | `03-Web原型开发/05-体质辨识页面.md` | ⬜ 待审议 | +| 3.6 | AI对话页面原型 | `03-Web原型开发/06-AI对话页面.md` | ⬜ 待审议 | +| 3.7 | 个人中心页面原型 | `03-Web原型开发/07-个人中心页面.md` | ⬜ 待审议 | + +### 第四阶段:后端开发 +| 序号 | 任务 | 文档 | 状态 | +|------|------|------|------| +| 4.1 | Go环境搭建 + 项目初始化 | `04-后端开发/01-环境搭建和项目初始化.md` | ⬜ 待审议 | +| 4.2 | 数据库和模型设计 | `04-后端开发/02-数据库和模型设计.md` | ⬜ 待审议 | +| 4.3 | 用户认证模块 | `04-后端开发/03-用户认证模块.md` | ⬜ 待审议 | +| 4.4 | 健康调查模块 | `04-后端开发/04-健康调查模块.md` | ⬜ 待审议 | +| 4.5 | 体质辨识模块 | `04-后端开发/05-体质辨识模块.md` | ⬜ 待审议 | +| 4.6 | AI对话模块 | `04-后端开发/06-AI对话模块.md` | ⬜ 待审议 | +| 4.7 | 健康档案模块 | `04-后端开发/07-健康档案模块.md` | ⬜ 待审议 | +| 4.8 | 保健品商城关联模块 | `04-后端开发/08-保健品商城关联模块.md` | ⬜ 待审议 | + +### 第五阶段:前后端对接 +| 序号 | 任务 | 文档 | 状态 | +|------|------|------|------| +| 5.1 | APP 对接后端 API | `05-前后端对接/01-APP对接后端.md` | ⬜ 待审议 | +| 5.2 | Web 对接后端 API | `05-前后端对接/02-Web对接后端.md` | ⬜ 待审议 | + +--- + +## 状态说明 + +| 标记 | 含义 | +|------|------| +| ⬜ | 待审议 | +| ✅ | 已通过 | +| 🔄 | 开发中 | +| ✔️ | 已完成 | + +--- + +## 审议流程 + +1. 按顺序查看每个文档 +2. 确认无误后标记"已通过" +3. 如需修改,在对话中说明 +4. 通过后开始执行对应开发任务 + +--- + +## 依赖关系 + +``` +第一阶段:环境搭建 (并行) + ├── APP环境 (React Native) + └── Web环境 (Vue 3) + ↓ +第二阶段:APP 原型开发 ──┐ + ├── 使用模拟数据,可完整交互演示 +第三阶段:Web 原型开发 ──┘ + ↓ +第四阶段:后端开发 (顺序) + 环境初始化 → 数据库模型 → 用户认证 → 健康调查 → 体质辨识 → AI对话 → 健康档案 → 保健品关联 + ↓ +第五阶段:前后端对接 + APP对接 + Web对接 → 联调测试 +``` + +--- + +## 模拟数据说明 + +原型阶段使用本地模拟数据,确保界面可完整交互: + +| 模块 | 模拟数据内容 | 数据文件 | +|------|------------|----------| +| 用户认证 | 预设测试账号,模拟登录 | `mock/user.ts` | +| 体质测试 | 60道问卷题目,本地计算结果 | `mock/constitution.ts` | +| 体质结果 | 9种体质详细描述和建议 | `mock/constitution.ts` | +| AI对话 | 预设问答对,模拟多轮对话 | `mock/chat.ts` | +| 产品推荐 | 36条保健品数据 | `mock/products.ts` | + +--- + +## 外部系统关联 + +| 系统 | 说明 | 对接方式 | +|------|------|----------| +| 保健品商城 | 关联保健品销售系统 | 产品链接跳转(mall_url) | +| 阿里云 AI | 通义千问大模型 | API 调用(后端对接)| + +--- + +## 后续扩展(暂不开发) + +| 功能 | 类型 | 备注 | +|------|------|------| +| 会员系统 | 积分制 | 消费/签到积分兑换权益 | diff --git a/TODOS/01-环境搭建/01-APP-ReactNative环境搭建.md b/TODOS/01-环境搭建/01-APP-ReactNative环境搭建.md new file mode 100644 index 0000000..c79a74e --- /dev/null +++ b/TODOS/01-环境搭建/01-APP-ReactNative环境搭建.md @@ -0,0 +1,148 @@ +# 01-APP React Native 环境搭建 + +## 目标 + +搭建 React Native 开发环境,确保可以正常创建和运行项目。 + +--- + +## 环境要求 + +| 工具 | 版本要求 | 说明 | +|------|----------|------| +| Node.js | 18+ | JavaScript 运行环境 | +| npm/yarn | 最新版 | 包管理器 | +| JDK | 17+ | Android 编译需要 | +| Android Studio | 最新版 | Android 开发环境 | +| Xcode | 14+ | iOS 开发环境(仅 macOS) | + +--- + +## 实施步骤 + +### 步骤 1:安装 Node.js + +```bash +# 下载并安装 Node.js 18+ +# https://nodejs.org/ + +# 验证安装 +node -v +npm -v +``` + +### 步骤 2:安装 JDK(Android 开发) + +```bash +# Windows:下载 OpenJDK 17 +# https://adoptium.net/ + +# macOS:使用 Homebrew +brew install openjdk@17 + +# 配置环境变量 +# JAVA_HOME 指向 JDK 安装目录 +``` + +### 步骤 3:安装 Android Studio + +1. 下载 Android Studio:https://developer.android.com/studio +2. 安装时选择以下组件: + - Android SDK + - Android SDK Platform + - Android Virtual Device +3. 打开 SDK Manager,安装: + - Android 14 (API 34) + - Android SDK Build-Tools 34 + - Android SDK Command-line Tools + - Android Emulator +4. 配置环境变量: + ```bash + # Windows (添加到系统环境变量) + ANDROID_HOME=C:\Users\<用户名>\AppData\Local\Android\Sdk + + # macOS/Linux (添加到 ~/.zshrc 或 ~/.bashrc) + export ANDROID_HOME=$HOME/Library/Android/sdk + export PATH=$PATH:$ANDROID_HOME/emulator + export PATH=$PATH:$ANDROID_HOME/platform-tools + ``` + +### 步骤 4:创建 Android 模拟器 + +1. 打开 Android Studio +2. 进入 Device Manager +3. 创建新设备: + - 选择 Pixel 6 + - 选择 API 34 系统镜像 + - 完成创建 + +### 步骤 5:安装 Xcode(仅 macOS) + +```bash +# 从 App Store 安装 Xcode + +# 安装命令行工具 +xcode-select --install + +# 安装 CocoaPods +sudo gem install cocoapods +``` + +### 步骤 6:验证环境 + +```bash +# 创建测试项目 +npx react-native init TestApp --template react-native-template-typescript + +# 进入项目 +cd TestApp + +# 启动 Metro +npm start + +# 新终端运行 Android +npm run android + +# 或运行 iOS (macOS) +npm run ios +``` + +--- + +## 常见问题 + +### Android 构建失败 +- 检查 ANDROID_HOME 环境变量 +- 检查 JDK 版本是否为 17+ +- 运行 `cd android && ./gradlew clean` + +### iOS 构建失败 +- 运行 `cd ios && pod install` +- 检查 Xcode 版本 + +### Metro 启动失败 +- 清除缓存:`npm start -- --reset-cache` +- 删除 node_modules 重新安装 + +--- + +## 验收标准 + +- [ ] Node.js 18+ 已安装 +- [ ] Android Studio 已安装 +- [ ] Android 模拟器可正常启动 +- [ ] 测试项目可在模拟器运行 +- [ ] (macOS) Xcode 已安装 +- [ ] (macOS) iOS 模拟器可正常运行 + +--- + +## 预计耗时 + +30-60 分钟(视网络情况) + +--- + +## 下一步 + +完成后进入 `01-环境搭建/02-Web前端Vue环境搭建.md` diff --git a/TODOS/01-环境搭建/02-Web前端Vue环境搭建.md b/TODOS/01-环境搭建/02-Web前端Vue环境搭建.md new file mode 100644 index 0000000..3103082 --- /dev/null +++ b/TODOS/01-环境搭建/02-Web前端Vue环境搭建.md @@ -0,0 +1,107 @@ +# 02-Web 前端 Vue 环境搭建 + +## 目标 + +搭建 Vue 3 + TypeScript + Vite 开发环境。 + +--- + +## 前置要求 + +- Node.js 18+ +- npm 或 pnpm 包管理器 + +--- + +## 实施步骤 + +### 步骤 1:安装 Node.js + +**Windows:** +1. 下载 Node.js LTS:https://nodejs.org/ +2. 选择 Windows Installer (.msi) +3. 双击安装,默认配置即可 +4. 验证: + ```bash + node -v + # 输出: v18.x.x 或更高 + npm -v + # 输出: 9.x.x 或更高 + ``` + +**macOS:** +```bash +brew install node +node -v +npm -v +``` + +**Linux:** +```bash +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt-get install -y nodejs +node -v +npm -v +``` + +### 步骤 2:配置 npm 镜像(国内加速) + +```bash +npm config set registry https://registry.npmmirror.com +npm config get registry +# 输出: https://registry.npmmirror.com +``` + +### 步骤 3:安装 pnpm(推荐) + +```bash +npm install -g pnpm +pnpm -v +``` + +### 步骤 4:验证 Vite 可用 + +```bash +# 创建测试项目 +npm create vite@latest vue-test -- --template vue-ts + +cd vue-test +npm install +npm run dev + +# 浏览器访问 http://localhost:5173 看到 Vue 页面即成功 +``` + +### 步骤 5:安装开发工具(可选) + +VSCode 推荐扩展: +- Vue - Official(Vue 官方扩展) +- TypeScript Vue Plugin (Volar) +- ESLint +- Prettier + +--- + +## 需要创建的文件 + +本步骤无需创建项目文件,仅环境配置。 + +--- + +## 验收标准 + +- [ ] `node -v` 显示 18+ +- [ ] `npm config get registry` 显示国内镜像 +- [ ] Vite 测试项目可以正常启动 + +--- + +## 预计耗时 + +10-15 分钟 + +--- + +## 下一步 + +完成后进入 `01-环境搭建/03-APP-ReactNative环境搭建.md` diff --git a/TODOS/01-环境搭建/03-APP-ReactNative环境搭建.md b/TODOS/01-环境搭建/03-APP-ReactNative环境搭建.md new file mode 100644 index 0000000..35e47af --- /dev/null +++ b/TODOS/01-环境搭建/03-APP-ReactNative环境搭建.md @@ -0,0 +1,165 @@ +# 03-APP React Native 环境搭建 + +## 目标 + +搭建 React Native 开发环境,支持 Android 和 iOS 开发。 + +--- + +## 前置要求 + +- Node.js 18+(已在上一步安装) +- JDK 17(Android 开发) +- Android Studio(Android 模拟器) +- Xcode(iOS 开发,仅 macOS) + +--- + +## 实施步骤 + +### 步骤 1:安装 JDK 17 + +**Windows:** + +1. 下载 OpenJDK 17:https://adoptium.net/ +2. 选择 Windows x64 Installer +3. 安装并配置环境变量: + ``` + JAVA_HOME = C:\Program Files\Eclipse Adoptium\jdk-17.x.x + Path 添加 %JAVA_HOME%\bin + ``` +4. 验证: + ```bash + java -version + # 输出: openjdk version "17.x.x" + ``` + +**macOS:** + +```bash +brew install openjdk@17 +echo 'export JAVA_HOME=$(/usr/libexec/java_home -v17)' >> ~/.zshrc +source ~/.zshrc +java -version +``` + +### 步骤 2:安装 Android Studio + +1. 下载:https://developer.android.com/studio +2. 安装时选择: + - Android SDK + - Android SDK Platform + - Android Virtual Device +3. 打开 Android Studio → SDK Manager +4. 安装 Android 13 (API 33) 或 Android 14 (API 34) +5. 配置环境变量: + +**Windows:** + +``` +ANDROID_HOME = C:\Users\<用户名>\AppData\Local\Android\Sdk +Path 添加: + %ANDROID_HOME%\platform-tools + %ANDROID_HOME%\emulator +``` + +**macOS/Linux:** + +```bash +echo 'export ANDROID_HOME=$HOME/Library/Android/sdk' >> ~/.zshrc +echo 'export PATH=$PATH:$ANDROID_HOME/platform-tools' >> ~/.zshrc +echo 'export PATH=$PATH:$ANDROID_HOME/emulator' >> ~/.zshrc +source ~/.zshrc +``` + +### 步骤 3:创建 Android 模拟器 + +1. 打开 Android Studio +2. Tools → Device Manager → Create Device +3. 选择 Pixel 6 或其他设备 +4. 选择系统镜像(推荐 API 33/34) +5. 完成创建并启动模拟器 + +### 步骤 4:安装 React Native CLI + +```bash +npm install -g react-native-cli +``` + +### 步骤 5:验证环境 + +```bash +# 创建测试项目 +npx react-native init RNTest --template react-native-template-typescript + +cd RNTest + +# 启动 Metro bundler +npm start + +# 新终端运行 Android +npm run android +# 或运行 iOS(仅 macOS) +npm run ios +``` + +### 步骤 6:iOS 环境(仅 macOS) + +```bash +# 安装 Xcode(App Store) +# 安装命令行工具 +xcode-select --install + +# 安装 CocoaPods +sudo gem install cocoapods + +# 在项目 ios 目录 +cd ios && pod install && cd .. +``` + +--- + +## 常见问题 + +### Android 模拟器启动失败 + +- 确保 BIOS 开启虚拟化(VT-x / AMD-V)- Windows 需开启 Hyper-V 或 HAXM + +### Metro bundler 端口占用 + +```bash +npx react-native start --port 8082 +``` + +### Gradle 下载慢 + +编辑 `android/gradle/wrapper/gradle-wrapper.properties`,使用国内镜像 + +--- + +## 需要创建的文件 + +本步骤无需创建项目文件,仅环境配置。 + +--- + +## 验收标准 + +- [ ] `java -version` 显示 JDK 17 +- [ ] `adb devices` 可列出模拟器/设备 +- [ ] 测试项目在模拟器正常运行 + +--- + +## 预计耗时 + +30-60 分钟(主要是下载时间) + +--- + +## 下一步 + +完成后进入 `02-后端开发/01-项目结构初始化.md` +\ +' +][;plplpl"lkkjijijijijijiiiiiii]' diff --git a/TODOS/02-APP原型开发/01-项目初始化和模拟数据.md b/TODOS/02-APP原型开发/01-项目初始化和模拟数据.md new file mode 100644 index 0000000..43c6759 --- /dev/null +++ b/TODOS/02-APP原型开发/01-项目初始化和模拟数据.md @@ -0,0 +1,745 @@ +# 01-项目初始化和模拟数据 + +## 目标 + +初始化 React Native 项目并搭建模拟数据服务,为原型开发提供完整的数据支持。 + +--- + +## 前置要求 + +- 已完成 React Native 环境搭建 +- Node.js 18+ +- Android Studio / Xcode + +--- + +## 实施步骤 + +### 步骤 1:创建 React Native 项目 + +```bash +# 进入项目目录 +cd I:/apps/demo/healthApps + +# 创建 React Native 项目 +npx react-native init HealthAIApp --template react-native-template-typescript + +# 进入项目 +cd HealthAIApp +``` + +### 步骤 2:安装核心依赖 + +```bash +# 导航 +npm install @react-navigation/native @react-navigation/bottom-tabs @react-navigation/native-stack +npm install react-native-screens react-native-safe-area-context + +# UI 组件库 +npm install react-native-paper react-native-vector-icons + +# 状态管理 +npm install zustand + +# 图表 +npm install react-native-gifted-charts react-native-linear-gradient react-native-svg + +# 存储 +npm install @react-native-async-storage/async-storage + +# 表单 +npm install react-hook-form + +# 工具 +npm install dayjs lodash-es +npm install -D @types/lodash-es +``` + +### 步骤 3:创建项目目录结构 + +``` +HealthAIApp/ +├── src/ +│ ├── api/ # API 接口(后续对接用) +│ ├── components/ # 公共组件 +│ │ ├── Button.tsx +│ │ ├── Card.tsx +│ │ ├── Input.tsx +│ │ └── Loading.tsx +│ ├── mock/ # 模拟数据 ⭐ +│ │ ├── index.ts # 统一导出 +│ │ ├── user.ts # 用户数据 +│ │ ├── constitution.ts # 体质问卷和结果 +│ │ ├── chat.ts # AI 对话数据 +│ │ └── products.ts # 产品数据 +│ ├── navigation/ # 导航配置 +│ │ └── index.tsx +│ ├── screens/ # 页面 +│ │ ├── auth/ # 登录相关 +│ │ ├── home/ # 首页 +│ │ ├── constitution/ # 体质辨识 +│ │ ├── chat/ # AI 对话 +│ │ └── profile/ # 个人中心 +│ ├── services/ # 业务服务 +│ │ └── mockService.ts # 模拟服务 +│ ├── stores/ # Zustand 状态 +│ │ ├── useAuthStore.ts +│ │ ├── useConstitutionStore.ts +│ │ └── useChatStore.ts +│ ├── theme/ # 主题配置 +│ │ └── index.ts +│ ├── types/ # TypeScript 类型 +│ │ └── index.ts +│ └── utils/ # 工具函数 +│ └── index.ts +├── App.tsx +└── package.json +``` + +### 步骤 4:创建模拟数据服务 + +#### 4.1 类型定义 `src/types/index.ts` + +```typescript +// 用户类型 +export interface User { + id: number; + phone: string; + nickname: string; + avatar: string; + surveyCompleted: boolean; +} + +// 体质类型 +export type ConstitutionType = + | 'pinghe' // 平和质 + | 'qixu' // 气虚质 + | 'yangxu' // 阳虚质 + | 'yinxu' // 阴虚质 + | 'tanshi' // 痰湿质 + | 'shire' // 湿热质 + | 'xueyu' // 血瘀质 + | 'qiyu' // 气郁质 + | 'tebing'; // 特禀质 + +// 体质问卷题目 +export interface ConstitutionQuestion { + id: number; + constitutionType: ConstitutionType; + question: string; + options: { value: number; label: string }[]; +} + +// 体质评估结果 +export interface ConstitutionResult { + primaryType: ConstitutionType; + scores: Record; + 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 => { + 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 = { + pinghe: '平和质', + qixu: '气虚质', + yangxu: '阳虚质', + yinxu: '阴虚质', + tanshi: '痰湿质', + shire: '湿热质', + xueyu: '血瘀质', + qiyu: '气郁质', + tebing: '特禀质', +}; + +// 体质描述 +export const constitutionDescriptions: Record = { + 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 +): ConstitutionResult => { + const scores: Record = { + 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 = { + 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 = { + 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 => { + 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 = { + 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` diff --git a/TODOS/02-APP原型开发/02-导航和布局设计.md b/TODOS/02-APP原型开发/02-导航和布局设计.md new file mode 100644 index 0000000..280c17c --- /dev/null +++ b/TODOS/02-APP原型开发/02-导航和布局设计.md @@ -0,0 +1,479 @@ +# 02-导航和布局设计 + +## 目标 + +配置 React Navigation 导航系统,实现 Tab 导航和 Stack 导航。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/首页.png` + +### 底部 Tab 导航规范 + +| Tab | 图标 | 文字 | +|-----|------|------| +| 首页 | 房屋图标 `home` | 首页 | +| AI问答 | 对话气泡 `chat-processing` | AI问答 | +| 体质分析 | 心电图 `chart-line-variant` | 体质分析 | +| 我的 | 用户图标 `account` | 我的 | + +### Tab 样式规范 + +```typescript +const tabBarOptions = { + activeTintColor: '#10B981', // 选中颜色 + inactiveTintColor: '#9CA3AF', // 未选中颜色 + style: { + height: 60, + paddingBottom: 8, + paddingTop: 8, + backgroundColor: '#FFFFFF', + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + }, + labelStyle: { + fontSize: 12, + }, +} +``` + +--- + +## 前置要求 + +- 项目结构已初始化 +- React Navigation 已安装 +- 模拟数据服务已创建 + +--- + +## 实施步骤 + +### 步骤 1:创建导航类型定义 + +创建 `src/navigation/types.ts`: +```typescript +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' +import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs' +import type { CompositeNavigationProp, RouteProp } from '@react-navigation/native' + +// Root Stack +export type RootStackParamList = { + Auth: undefined + Main: undefined +} + +// Auth Stack +export type AuthStackParamList = { + Login: undefined +} + +// Main Tab +export type MainTabParamList = { + HomeTab: undefined + ChatTab: undefined + ConstitutionTab: undefined + ProfileTab: undefined +} + +// Home Stack +export type HomeStackParamList = { + Home: undefined +} + +// Chat Stack +export type ChatStackParamList = { + ChatList: undefined + ChatDetail: { id: string } +} + +// Constitution Stack +export type ConstitutionStackParamList = { + ConstitutionHome: undefined + ConstitutionQuestions: undefined + ConstitutionResult: undefined +} + +// Profile Stack +export type ProfileStackParamList = { + ProfileHome: undefined + HealthRecord: undefined +} + +// Navigation Props +export type RootNavigationProp = NativeStackNavigationProp + +export type ChatNavigationProp = CompositeNavigationProp< + NativeStackNavigationProp, + BottomTabNavigationProp +> + +// Route Props +export type ChatDetailRouteProp = RouteProp +``` + +### 步骤 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((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() + +const MainTabNavigator = () => { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ) +} + +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() + +const HomeNavigator = () => { + return ( + + + + ) +} + +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() + +const ChatNavigator = () => { + return ( + + + + + ) +} + +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() + +const ConstitutionNavigator = () => { + return ( + + + + + + ) +} + +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() + +const ProfileNavigator = () => { + return ( + + + + + ) +} + +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() + +const RootNavigator = () => { + const { isLoggedIn } = useAuthStore() + + return ( + + {!isLoggedIn ? ( + + ) : ( + + )} + + ) +} + +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 ( + + + + + + + + ) +} + +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` diff --git a/TODOS/02-APP原型开发/03-登录页面.md b/TODOS/02-APP原型开发/03-登录页面.md new file mode 100644 index 0000000..2fb74eb --- /dev/null +++ b/TODOS/02-APP原型开发/03-登录页面.md @@ -0,0 +1,295 @@ +# 03-登录页面(原型) + +## 目标 + +实现 APP 端登录页面原型,使用模拟数据验证登录。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/登录页.png` + +### 页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色渐变背景 (`#10B981 → #2EC4B6`) + 医疗插图 | +| Logo | "AI健康助手" 标题(白色 32px)+ slogan | +| 表单卡片 | 白色背景,圆角 16px | +| 输入框 | 圆角 12px,左侧带图标 | +| 主按钮 | 绿色 `#10B981`,圆角 24px,高度 48px | + +--- + +## 前置要求 + +- 导航配置完成 +- 模拟数据服务已创建 + +--- + +## 实施步骤 + +### 步骤 1:创建登录页面 + +创建 `src/screens/auth/LoginScreen.tsx`: +```typescript +import React, { useState } from 'react' +import { + View, + Text, + StyleSheet, + KeyboardAvoidingView, + Platform, + ScrollView, + Alert, + Image, +} from 'react-native' +import { TextInput, Button } from 'react-native-paper' +import { useAuthStore } from '../../stores/useAuthStore' +import { mockLogin } from '../../mock/user' + +const LoginScreen = () => { + const { login } = useAuthStore() + + const [phone, setPhone] = useState('13800138000') // 预填测试账号 + const [code, setCode] = useState('') + const [loading, setLoading] = useState(false) + const [countdown, setCountdown] = useState(0) + + // 模拟发送验证码 + const handleSendCode = () => { + if (!phone.trim() || phone.length !== 11) { + Alert.alert('提示', '请输入正确的手机号') + return + } + + // 开始倒计时 + setCountdown(60) + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + + Alert.alert('提示', '验证码已发送,测试验证码为:123456') + } + + const handleLogin = async () => { + if (!phone.trim()) { + Alert.alert('提示', '请输入手机号') + return + } + if (!code.trim()) { + Alert.alert('提示', '请输入验证码') + return + } + + setLoading(true) + try { + const user = await mockLogin(phone, code) + if (user) { + login(user) + } else { + Alert.alert('登录失败', '验证码错误,请输入:123456') + } + } finally { + setLoading(false) + } + } + + return ( + + + {/* 顶部背景 */} + + AI健康助手 + 您的智能健康管家 + + + {/* 登录表单 */} + + 手机号登录 + + } + /> + + + } + /> + + + + + + + 测试账号:13800138000,验证码:123456 + + + + 登录即表示同意《用户协议》和《隐私政策》 + + + + + ) +} + +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` diff --git a/TODOS/02-APP原型开发/04-首页.md b/TODOS/02-APP原型开发/04-首页.md new file mode 100644 index 0000000..c209dc5 --- /dev/null +++ b/TODOS/02-APP原型开发/04-首页.md @@ -0,0 +1,417 @@ +# 04-首页(原型) + +## 目标 + +实现 APP 首页原型,展示用户体质信息、快捷入口和健康提示。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/首页.png` + +### 页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色背景 `#10B981`,用户问候语 | +| 体质卡片 | 白色卡片,显示当前体质类型和简介 | +| 快捷入口 | 4个功能入口(AI问诊、体质测试、健康档案、商城) | +| 健康提示 | 每日健康小贴士 | +| 推荐产品 | 根据体质推荐的保健品(横向滚动) | + +--- + +## 前置要求 + +- 导航配置完成 +- 模拟数据服务已创建 +- 登录页面完成 + +--- + +## 实施步骤 + +### 步骤 1:创建体质状态 Store + +创建 `src/stores/useConstitutionStore.ts`: +```typescript +import { create } from 'zustand' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { ConstitutionResult } from '../types' + +interface ConstitutionState { + result: ConstitutionResult | null + setResult: (result: ConstitutionResult) => void + clearResult: () => void +} + +export const useConstitutionStore = create((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() + 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 ( + + {/* 顶部问候 */} + + + + {getGreeting()},{user?.nickname || '用户'} + + 今天也要保持健康哦~ + + + + + {/* 体质卡片 */} + + + {result ? ( + <> + + 我的体质 + navigation.navigate('ConstitutionTab', { screen: 'ConstitutionResult' })} + > + 查看详情 → + + + + + + + {constitutionNames[result.primaryType]} + + + + {constitutionDescriptions[result.primaryType].description} + + + + ) : ( + navigation.navigate('ConstitutionTab')} + > + + 还未进行体质测试 + 点击开始测试,了解您的体质类型 + + )} + + + + {/* 快捷入口 */} + + {quickActions.map((action, index) => ( + + + + + {action.label} + + ))} + + + {/* 健康提示 */} + + + + + 今日健康提示 + + {result + ? constitutionDescriptions[result.primaryType].suggestions[0] + : '保持良好的作息习惯,每天喝足8杯水,适当运动有益身心健康。' + } + + + + + + {/* 推荐产品 */} + + + + {result ? '适合您的调养产品' : '热门保健品'} + + Linking.openURL('https://mall.example.com')}> + 查看更多 → + + + + {recommendedProducts.map((product) => ( + Linking.openURL(product.mallUrl)} + > + + + + {product.name} + ¥{product.price} + + ))} + + + + ) +} + +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` diff --git a/TODOS/02-APP原型开发/05-体质辨识页面.md b/TODOS/02-APP原型开发/05-体质辨识页面.md new file mode 100644 index 0000000..f80f547 --- /dev/null +++ b/TODOS/02-APP原型开发/05-体质辨识页面.md @@ -0,0 +1,466 @@ +# 05-体质辨识页面(原型) + +## 目标 + +实现 APP 端中医体质辨识问卷和结果展示,使用本地模拟数据计算结果。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/体质页.png`、`files/ui/体质检测.png`、`files/ui/体质分析.png` + +--- + +## 页面组成 + +1. **体质首页** - 介绍页面,引导用户开始测试 +2. **问卷页面** - 60道题目,逐题作答 +3. **结果页面** - 显示体质类型、雷达图、调养建议 + +--- + +## 前置要求 + +- 导航配置完成 +- 模拟数据服务已创建(`src/mock/constitution.ts`) + +--- + +## 实施步骤 + +### 步骤 1:体质首页 + +创建 `src/screens/constitution/ConstitutionHomeScreen.tsx`: +```typescript +import React from 'react' +import { View, ScrollView, StyleSheet } from 'react-native' +import { Text, Card, Button } from 'react-native-paper' +import Icon from 'react-native-vector-icons/MaterialCommunityIcons' +import { useNavigation } from '@react-navigation/native' +import { useConstitutionStore } from '../../stores/useConstitutionStore' +import { constitutionNames, constitutionDescriptions } from '../../mock/constitution' + +const ConstitutionHomeScreen = () => { + const navigation = useNavigation() + const { result } = useConstitutionStore() + + const steps = [ + { icon: 'clipboard-text', title: '回答问卷', desc: '60道题目,约10分钟' }, + { icon: 'calculator', title: '智能分析', desc: '根据答案计算体质' }, + { icon: 'file-document', title: '获取报告', desc: '体质类型和调养建议' }, + ] + + return ( + + {/* 已有结果时显示 */} + {result && ( + + + + + 您已完成体质测评 + + + + {constitutionNames[result.primaryType]} + + + {constitutionDescriptions[result.primaryType].description} + + + + + + )} + + {/* 介绍卡片 */} + + + 中医体质自测 + + 中医体质辨识是以中医理论为指导,根据人体生理特点分为9种基本体质类型。 + 了解自己的体质类型,有助于选择适合的养生方法。 + + + + + {/* 步骤说明 */} + + {steps.map((step, index) => ( + + + + + + {step.title} + {step.desc} + + {index < steps.length - 1 && } + + ))} + + + {/* 开始按钮 */} + + + + 建议每3-6个月重新测评一次,以跟踪体质变化 + + + ) +} + +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() + const { setResult } = useConstitutionStore() + + const [currentIndex, setCurrentIndex] = useState(0) + const [answers, setAnswers] = useState>({}) + + 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 ( + + {/* 进度条 */} + + + 第 {currentIndex + 1} 题 / 共 {questions.length} 题 + + + + + {/* 问题卡片 */} + + + + {currentQuestion.question} + + + {currentQuestion.options.map((option) => ( + + ))} + + + + + + {/* 导航按钮 */} + + + + {isLastQuestion ? ( + + ) : ( + + )} + + + ) +} + +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() + const { result } = useConstitutionStore() + + if (!result) { + return ( + + 暂无测评结果 + + + ) + } + + 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 ( + + {/* 主体质卡片 */} + + + + {constitutionNames[result.primaryType]} + {result.scores[result.primaryType]}分 + {info.description} + + + + {/* 体质得分 */} + + + + {allScores.map((item) => ( + + {item.name} + + + + {item.score} + + ))} + + + + {/* 体质特征 */} + + + + + {info.features.map((feature, index) => ( + {feature} + ))} + + + + + {/* 调养建议 */} + + + + {info.suggestions.map((suggestion, index) => ( + + + {suggestion} + + ))} + + + + {/* 推荐产品 */} + + + + {products.map((product) => ( + + {product.name} + ¥{product.price} + + ))} + + + + {/* 操作按钮 */} + + + + + ) +} + +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` diff --git a/TODOS/02-APP原型开发/06-AI对话页面.md b/TODOS/02-APP原型开发/06-AI对话页面.md new file mode 100644 index 0000000..0391643 --- /dev/null +++ b/TODOS/02-APP原型开发/06-AI对话页面.md @@ -0,0 +1,387 @@ +# 06-AI对话页面(原型) + +## 目标 + +实现 APP 端 AI 健康问诊对话功能,使用模拟数据模拟多轮对话效果。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/问答页.png`、`files/ui/问答对话.png` + +--- + +## 前置要求 + +- 导航配置完成 +- 模拟数据服务已创建(`src/mock/chat.ts`) + +--- + +## 实施步骤 + +### 步骤 1:创建对话状态 Store + +创建 `src/stores/useChatStore.ts`: +```typescript +import { create } from 'zustand' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { Conversation, Message } from '../types' + +interface ChatState { + conversations: Conversation[] + addConversation: (conv: Conversation) => void + deleteConversation: (id: string) => void + addMessage: (convId: string, message: Message) => void +} + +export const useChatStore = create((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() + 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] }) => ( + navigation.navigate('ChatDetail', { id: item.id })}> + + + + + + + {item.title} + + {dayjs(item.updatedAt).format('MM-DD HH:mm')} + + + handleDelete(item.id)} + /> + + + + ) + + return ( + + {conversations.length === 0 ? ( + + + 暂无对话记录 + 点击下方按钮开始咨询 + + ) : ( + item.id} + contentContainerStyle={styles.list} + /> + )} + + + + ) +} + +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() + const { id } = route.params + const { conversations, addMessage } = useChatStore() + const { user } = useAuthStore() + const flatListRef = useRef(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 ( + + {!isUser && ( + + )} + + + {item.content} + + + {isUser && ( + + )} + + ) + } + + return ( + + {/* 欢迎消息 */} + {messages.length === 0 && ( + + + AI健康助手 + + 您好!我是AI健康助手,可以为您提供健康咨询和建议。 + {'\n'}请描述您的症状或健康问题。 + + + )} + + {/* 消息列表 */} + item.id} + contentContainerStyle={styles.messageList} + onContentSizeChange={() => flatListRef.current?.scrollToEnd()} + /> + + {/* 输入中提示 */} + {sending && ( + + AI 正在思考... + + )} + + {/* 输入区域 */} + + + + + + {/* 免责声明 */} + + + AI 建议仅供参考,不构成医疗诊断,如有需要请就医 + + + + ) +} + +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` diff --git a/TODOS/02-APP原型开发/07-个人中心页面.md b/TODOS/02-APP原型开发/07-个人中心页面.md new file mode 100644 index 0000000..c0480c8 --- /dev/null +++ b/TODOS/02-APP原型开发/07-个人中心页面.md @@ -0,0 +1,383 @@ +# 07-个人中心页面(原型) + +## 目标 + +实现 APP 端个人中心和健康档案管理页面原型。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/我的.png` + +--- + +## 前置要求 + +- 导航配置完成 +- 认证状态 Store 已创建 +- 体质状态 Store 已创建 + +--- + +## 实施步骤 + +### 步骤 1:个人中心页面 + +创建 `src/screens/profile/ProfileHomeScreen.tsx`: +```typescript +import React from 'react' +import { View, ScrollView, StyleSheet, Alert, Linking } from 'react-native' +import { Text, Avatar, Card, List, Button, Divider } from 'react-native-paper' +import Icon from 'react-native-vector-icons/MaterialCommunityIcons' +import { useNavigation } from '@react-navigation/native' +import { useAuthStore } from '../../stores/useAuthStore' +import { useConstitutionStore } from '../../stores/useConstitutionStore' +import { constitutionNames } from '../../mock/constitution' + +const ProfileHomeScreen = () => { + const navigation = useNavigation() + const { user, logout } = useAuthStore() + const { result } = useConstitutionStore() + + const handleLogout = () => { + Alert.alert('提示', '确定要退出登录吗?', [ + { text: '取消', style: 'cancel' }, + { text: '确定', style: 'destructive', onPress: () => logout() }, + ]) + } + + return ( + + {/* 用户信息卡片 */} + + + + + {user?.nickname || '用户'} + {user?.phone} + {result && ( + + + + {constitutionNames[result.primaryType]} + + + )} + + + + + {/* 健康管理 */} + + + ( + + + + )} + right={(props) => } + onPress={() => navigation.navigate('HealthRecord')} + style={styles.listItem} + /> + + ( + + + + )} + right={(props) => } + onPress={() => navigation.navigate('ConstitutionTab', { screen: 'ConstitutionResult' })} + style={styles.listItem} + /> + + ( + + + + )} + right={(props) => } + onPress={() => navigation.navigate('ChatTab')} + style={styles.listItem} + /> + + + {/* 其他设置 */} + + + ( + + + + )} + right={(props) => } + onPress={() => Linking.openURL('https://mall.example.com')} + style={styles.listItem} + /> + + ( + + + + )} + right={(props) => } + onPress={() => Alert.alert('关于我们', '健康AI助手 v1.0.0\n\n结合中医体质辨识理论,为您提供个性化健康建议。')} + style={styles.listItem} + /> + + + {/* 退出登录 */} + + + 版本 1.0.0(原型版) + + ) +} + +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 ( + + {/* 基础信息 */} + + } + /> + + + + + + + + + + + + + + {/* 体质信息 */} + + } + /> + + {result ? ( + + + {constitutionNames[result.primaryType]} + + + {constitutionDescriptions[result.primaryType].description} + + + 测评时间:{new Date(result.assessedAt).toLocaleDateString()} + + + ) : ( + 暂无体质测评记录 + )} + + + + {/* 既往病史 */} + + } + /> + + {mockProfile.medicalHistory.length > 0 ? ( + + {mockProfile.medicalHistory.map((item, index) => ( + {item} + ))} + + ) : ( + 暂无病史记录 + )} + + + + {/* 过敏信息 */} + + } + /> + + {mockProfile.allergyRecords.length > 0 ? ( + + {mockProfile.allergyRecords.map((item, index) => ( + {item} + ))} + + ) : ( + 暂无过敏信息 + )} + + + + {/* 生活习惯 */} + + } + /> + + + + + + + + + + + 以上为模拟数据,后续将支持编辑和同步 + + + ) +} + +const InfoItem = ({ label, value }: { label: string; value: string }) => ( + + {label} + {value} + +) + +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` diff --git a/TODOS/02-后端开发/01-项目结构初始化.md b/TODOS/02-后端开发/01-项目结构初始化.md new file mode 100644 index 0000000..157cf36 --- /dev/null +++ b/TODOS/02-后端开发/01-项目结构初始化.md @@ -0,0 +1,195 @@ +# 01-后端项目结构初始化 + +## 目标 + +创建 Go + Gin 后端项目的基础目录结构和配置文件。 + +--- + +## 前置要求 + +- Go 1.21+ 已安装 +- 环境变量已配置 + +--- + +## 实施步骤 + +### 步骤 1:创建项目目录 + +```bash +cd I:\apps\demo\healthApps +mkdir -p server +cd server +``` + +### 步骤 2:初始化 Go 模块 + +```bash +go mod init health-ai +``` + +### 步骤 3:创建目录结构 + +```bash +mkdir -p cmd/server +mkdir -p internal/api/handler +mkdir -p internal/api/middleware +mkdir -p internal/model +mkdir -p internal/service +mkdir -p internal/repository/impl +mkdir -p internal/config +mkdir -p internal/database +mkdir -p pkg/jwt +mkdir -p pkg/response +mkdir -p pkg/utils +mkdir -p data +``` + +### 步骤 4:安装核心依赖 + +```bash +# Web 框架 +go get -u github.com/gin-gonic/gin + +# ORM +go get -u gorm.io/gorm +go get -u gorm.io/driver/sqlite + +# 配置管理 +go get -u github.com/spf13/viper + +# 日志 +go get -u go.uber.org/zap + +# JWT +go get -u github.com/golang-jwt/jwt/v5 + +# 密码加密 +go get -u golang.org/x/crypto/bcrypt + +# 参数验证 +go get -u github.com/go-playground/validator/v10 + +# 跨域 +go get -u github.com/gin-contrib/cors +``` + +### 步骤 5:创建配置文件 + +创建 `server/config.yaml`: +```yaml +server: + port: 8080 + mode: debug # debug, release, test + +database: + driver: sqlite # sqlite, postgres, mysql + sqlite: + path: ./data/health.db + postgres: + host: localhost + port: 5432 + user: postgres + password: "" + dbname: health_app + mysql: + host: localhost + port: 3306 + user: root + password: "" + dbname: health_app + +jwt: + secret: your-secret-key-change-in-production + expire_hours: 24 + +ai: + provider: openai # openai, qwen + api_key: "" + base_url: "" +``` + +### 步骤 6:创建入口文件 + +创建 `server/cmd/server/main.go`: +```go +package main + +import ( + "log" +) + +func main() { + log.Println("Health AI Server Starting...") + // TODO: 初始化配置、数据库、路由 +} +``` + +### 步骤 7:验证项目 + +```bash +cd server +go mod tidy +go run cmd/server/main.go +# 输出: Health AI Server Starting... +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `server/go.mod` | Go 模块定义 | +| `server/config.yaml` | 配置文件 | +| `server/cmd/server/main.go` | 程序入口 | + +--- + +## 最终目录结构 + +``` +server/ +├── cmd/ +│ └── server/ +│ └── main.go +├── internal/ +│ ├── api/ +│ │ ├── handler/ +│ │ └── middleware/ +│ ├── model/ +│ ├── service/ +│ ├── repository/ +│ │ └── impl/ +│ ├── config/ +│ └── database/ +├── pkg/ +│ ├── jwt/ +│ ├── response/ +│ └── utils/ +├── data/ +├── config.yaml +├── go.mod +└── go.sum +``` + +--- + +## 验收标准 + +- [ ] 目录结构创建完成 +- [ ] `go mod tidy` 无报错 +- [ ] `go run cmd/server/main.go` 正常输出 + +--- + +## 预计耗时 + +10-15 分钟 + +--- + +## 下一步 + +完成后进入 `02-后端开发/02-数据库和模型设计.md` diff --git a/TODOS/02-后端开发/02-数据库和模型设计.md b/TODOS/02-后端开发/02-数据库和模型设计.md new file mode 100644 index 0000000..4574de2 --- /dev/null +++ b/TODOS/02-后端开发/02-数据库和模型设计.md @@ -0,0 +1,384 @@ +# 02-数据库和模型设计 + +## 目标 + +实现数据库连接模块和所有数据模型定义,支持多数据库切换。 + +--- + +## 前置要求 + +- 项目结构已初始化 +- 依赖已安装 + +--- + +## 实施步骤 + +### 步骤 1:创建配置加载模块 + +创建 `server/internal/config/config.go`: +```go +package config + +import ( + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig + AI AIConfig +} + +type ServerConfig struct { + Port int + Mode string +} + +type DatabaseConfig struct { + Driver string + SQLite SQLiteConfig + Postgres PostgresConfig + MySQL MySQLConfig +} + +type SQLiteConfig struct { + Path string +} + +type PostgresConfig struct { + Host string + Port int + User string + Password string + DBName string +} + +type MySQLConfig struct { + Host string + Port int + User string + Password string + DBName string +} + +type JWTConfig struct { + Secret string + ExpireHours int `mapstructure:"expire_hours"` +} + +type AIConfig struct { + Provider string + APIKey string `mapstructure:"api_key"` + BaseURL string `mapstructure:"base_url"` +} + +var AppConfig *Config + +func LoadConfig(path string) error { + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + return err + } + AppConfig = &Config{} + return viper.Unmarshal(AppConfig) +} +``` + +### 步骤 2:创建数据库连接模块 + +创建 `server/internal/database/database.go`: +```go +package database + +import ( + "fmt" + + "health-ai/internal/config" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDatabase(cfg *config.DatabaseConfig) error { + var err error + + switch cfg.Driver { + case "sqlite": + DB, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), &gorm.Config{}) + case "postgres": + // TODO: 添加 PostgreSQL 支持 + // dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + // cfg.Postgres.Host, cfg.Postgres.Port, cfg.Postgres.User, cfg.Postgres.Password, cfg.Postgres.DBName) + // DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + return fmt.Errorf("postgres driver not implemented yet") + case "mysql": + // TODO: 添加 MySQL 支持 + return fmt.Errorf("mysql driver not implemented yet") + default: + return fmt.Errorf("unsupported database driver: %s", cfg.Driver) + } + + return err +} + +func AutoMigrate(models ...interface{}) error { + return DB.AutoMigrate(models...) +} +``` + +### 步骤 3:创建数据模型 + +创建 `server/internal/model/user.go`: +```go +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// User 用户表 +type User struct { + gorm.Model + Phone string `gorm:"uniqueIndex;size:20"` + Email string `gorm:"uniqueIndex;size:100"` + PasswordHash string `gorm:"size:255"` + Nickname string `gorm:"size:50"` + Avatar string `gorm:"size:255"` + SurveyCompleted bool `gorm:"default:false"` +} + +// HealthProfile 健康档案 +type HealthProfile struct { + gorm.Model + UserID uint `gorm:"uniqueIndex"` + Name string `gorm:"size:50"` + BirthDate *time.Time + Gender string `gorm:"size:10"` // male, female + Height float64 // cm + Weight float64 // kg + BMI float64 + BloodType string `gorm:"size:10"` // A, B, AB, O + Occupation string `gorm:"size:50"` + MaritalStatus string `gorm:"size:20"` // single, married, divorced + Region string `gorm:"size:100"` +} + +// LifestyleInfo 生活习惯 +type LifestyleInfo struct { + gorm.Model + UserID uint `gorm:"uniqueIndex"` + SleepTime string `gorm:"size:10"` // HH:MM + WakeTime string `gorm:"size:10"` + SleepQuality string `gorm:"size:20"` // good, normal, poor + MealRegularity string `gorm:"size:20"` // regular, irregular + DietPreference string `gorm:"size:50"` // 偏好 + DailyWaterML int // 每日饮水量 ml + ExerciseFrequency string `gorm:"size:20"` // never, sometimes, often, daily + ExerciseType string `gorm:"size:100"` + ExerciseDurationMin int // 每次运动时长 + IsSmoker bool + AlcoholFrequency string `gorm:"size:20"` // never, sometimes, often +} +``` + +创建 `server/internal/model/health.go`: +```go +package model + +import "gorm.io/gorm" + +// MedicalHistory 既往病史 +type MedicalHistory struct { + gorm.Model + HealthProfileID uint + DiseaseName string `gorm:"size:100"` + DiseaseType string `gorm:"size:50"` // chronic, surgery, other + DiagnosedDate string `gorm:"size:20"` + Status string `gorm:"size:20"` // cured, treating, controlled + Notes string `gorm:"type:text"` +} + +// FamilyHistory 家族病史 +type FamilyHistory struct { + gorm.Model + HealthProfileID uint + Relation string `gorm:"size:20"` // father, mother, grandparent + DiseaseName string `gorm:"size:100"` + Notes string `gorm:"type:text"` +} + +// AllergyRecord 过敏记录 +type AllergyRecord struct { + gorm.Model + HealthProfileID uint + AllergyType string `gorm:"size:20"` // drug, food, other + Allergen string `gorm:"size:100"` + Severity string `gorm:"size:20"` // mild, moderate, severe + ReactionDesc string `gorm:"type:text"` +} +``` + +创建 `server/internal/model/constitution.go`: +```go +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// ConstitutionAssessment 体质测评记录 +type ConstitutionAssessment struct { + gorm.Model + UserID uint + AssessedAt time.Time + Scores string `gorm:"type:text"` // JSON: 各体质得分 + PrimaryConstitution string `gorm:"size:20"` // 主要体质 + SecondaryConstitutions string `gorm:"type:text"` // JSON: 次要体质 + Recommendations string `gorm:"type:text"` // JSON: 调养建议 +} + +// AssessmentAnswer 问卷答案 +type AssessmentAnswer struct { + gorm.Model + AssessmentID uint + QuestionID uint + Score int // 1-5 +} + +// QuestionBank 问卷题库 +type QuestionBank struct { + gorm.Model + ConstitutionType string `gorm:"size:20"` // 体质类型 + QuestionText string `gorm:"type:text"` + Options string `gorm:"type:text"` // JSON: 选项 + OrderNum int +} +``` + +创建 `server/internal/model/conversation.go`: +```go +package model + +import "gorm.io/gorm" + +// Conversation 对话 +type Conversation struct { + gorm.Model + UserID uint + Title string `gorm:"size:200"` + Messages []Message +} + +// Message 消息 +type Message struct { + gorm.Model + ConversationID uint + Role string `gorm:"size:20"` // user, assistant, system + Content string `gorm:"type:text"` +} +``` + +### 步骤 4:创建模型聚合文件 + +创建 `server/internal/model/models.go`: +```go +package model + +// AllModels 返回所有需要迁移的模型 +func AllModels() []interface{} { + return []interface{}{ + &User{}, + &HealthProfile{}, + &LifestyleInfo{}, + &MedicalHistory{}, + &FamilyHistory{}, + &AllergyRecord{}, + &ConstitutionAssessment{}, + &AssessmentAnswer{}, + &QuestionBank{}, + &Conversation{}, + &Message{}, + } +} +``` + +### 步骤 5:更新主程序 + +更新 `server/cmd/server/main.go`: +```go +package main + +import ( + "log" + + "health-ai/internal/config" + "health-ai/internal/database" + "health-ai/internal/model" +) + +func main() { + // 加载配置 + if err := config.LoadConfig("config.yaml"); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + log.Println("Config loaded") + + // 初始化数据库 + if err := database.InitDatabase(&config.AppConfig.Database); err != nil { + log.Fatalf("Failed to init database: %v", err) + } + log.Println("Database connected") + + // 自动迁移 + if err := database.AutoMigrate(model.AllModels()...); err != nil { + log.Fatalf("Failed to migrate: %v", err) + } + log.Println("Database migrated") + + log.Println("Health AI Server Ready!") +} +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `internal/config/config.go` | 配置加载 | +| `internal/database/database.go` | 数据库连接 | +| `internal/model/user.go` | 用户相关模型 | +| `internal/model/health.go` | 健康相关模型 | +| `internal/model/constitution.go` | 体质相关模型 | +| `internal/model/conversation.go` | 对话相关模型 | +| `internal/model/models.go` | 模型聚合 | + +--- + +## 验收标准 + +- [ ] 配置文件可正常加载 +- [ ] SQLite 数据库文件自动创建 +- [ ] 所有表自动迁移成功 +- [ ] `data/health.db` 文件生成 + +--- + +## 预计耗时 + +20-30 分钟 + +--- + +## 下一步 + +完成后进入 `02-后端开发/03-用户认证模块.md` diff --git a/TODOS/02-后端开发/03-用户认证模块.md b/TODOS/02-后端开发/03-用户认证模块.md new file mode 100644 index 0000000..e3e18d2 --- /dev/null +++ b/TODOS/02-后端开发/03-用户认证模块.md @@ -0,0 +1,522 @@ +# 03-用户认证模块 + +## 目标 + +实现用户注册、登录、Token 刷新等认证功能。 + +--- + +## 前置要求 + +- 数据库和模型已完成 +- JWT 依赖已安装 + +--- + +## 实施步骤 + +### 步骤 1:创建统一响应工具 + +创建 `server/pkg/response/response.go`: +```go +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: "success", + Data: data, + }) +} + +func Error(c *gin.Context, code int, message string) { + c.JSON(http.StatusOK, Response{ + Code: code, + Message: message, + }) +} + +func Unauthorized(c *gin.Context, message string) { + c.JSON(http.StatusUnauthorized, Response{ + Code: 401, + Message: message, + }) +} +``` + +### 步骤 2:创建 JWT 工具 + +创建 `server/pkg/jwt/jwt.go`: +```go +package jwt + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret []byte +var expireHours int + +func Init(secret string, hours int) { + jwtSecret = []byte(secret) + expireHours = hours +} + +type Claims struct { + UserID uint `json:"user_id"` + jwt.RegisteredClaims +} + +func GenerateToken(userID uint) (string, error) { + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + return nil, errors.New("invalid token") +} +``` + +### 步骤 3:创建认证中间件 + +创建 `server/internal/api/middleware/auth.go`: +```go +package middleware + +import ( + "strings" + + "health-ai/pkg/jwt" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.Unauthorized(c, "未提供认证信息") + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + response.Unauthorized(c, "认证格式错误") + c.Abort() + return + } + + claims, err := jwt.ParseToken(parts[1]) + if err != nil { + response.Unauthorized(c, "Token无效或已过期") + c.Abort() + return + } + + c.Set("userID", claims.UserID) + c.Next() + } +} + +func GetUserID(c *gin.Context) uint { + userID, _ := c.Get("userID") + return userID.(uint) +} +``` + +### 步骤 4:创建用户 Repository + +创建 `server/internal/repository/interface.go`: +```go +package repository + +import "health-ai/internal/model" + +type UserRepository interface { + Create(user *model.User) error + GetByID(id uint) (*model.User, error) + GetByPhone(phone string) (*model.User, error) + GetByEmail(email string) (*model.User, error) + Update(user *model.User) error +} +``` + +创建 `server/internal/repository/impl/user.go`: +```go +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type UserRepositoryImpl struct{} + +func NewUserRepository() *UserRepositoryImpl { + return &UserRepositoryImpl{} +} + +func (r *UserRepositoryImpl) Create(user *model.User) error { + return database.DB.Create(user).Error +} + +func (r *UserRepositoryImpl) GetByID(id uint) (*model.User, error) { + var user model.User + err := database.DB.First(&user, id).Error + return &user, err +} + +func (r *UserRepositoryImpl) GetByPhone(phone string) (*model.User, error) { + var user model.User + err := database.DB.Where("phone = ?", phone).First(&user).Error + return &user, err +} + +func (r *UserRepositoryImpl) GetByEmail(email string) (*model.User, error) { + var user model.User + err := database.DB.Where("email = ?", email).First(&user).Error + return &user, err +} + +func (r *UserRepositoryImpl) Update(user *model.User) error { + return database.DB.Save(user).Error +} +``` + +### 步骤 5:创建认证 Service + +创建 `server/internal/service/auth.go`: +```go +package service + +import ( + "errors" + + "health-ai/internal/model" + "health-ai/internal/repository/impl" + "health-ai/pkg/jwt" + + "golang.org/x/crypto/bcrypt" +) + +type AuthService struct { + userRepo *impl.UserRepositoryImpl +} + +func NewAuthService() *AuthService { + return &AuthService{ + userRepo: impl.NewUserRepository(), + } +} + +type RegisterRequest struct { + Phone string `json:"phone" binding:"required"` + Password string `json:"password" binding:"required,min=6"` + Nickname string `json:"nickname"` +} + +type LoginRequest struct { + Phone string `json:"phone" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type AuthResponse struct { + Token string `json:"token"` + UserID uint `json:"user_id"` + Nickname string `json:"nickname"` + SurveyCompleted bool `json:"survey_completed"` +} + +func (s *AuthService) Register(req *RegisterRequest) (*AuthResponse, error) { + // 检查手机号是否已注册 + existing, _ := s.userRepo.GetByPhone(req.Phone) + if existing.ID > 0 { + return nil, errors.New("手机号已注册") + } + + // 加密密码 + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + // 创建用户 + user := &model.User{ + Phone: req.Phone, + PasswordHash: string(hash), + Nickname: req.Nickname, + } + if user.Nickname == "" { + user.Nickname = "用户" + req.Phone[len(req.Phone)-4:] + } + + if err := s.userRepo.Create(user); err != nil { + return nil, err + } + + // 生成 Token + token, err := jwt.GenerateToken(user.ID) + if err != nil { + return nil, err + } + + return &AuthResponse{ + Token: token, + UserID: user.ID, + Nickname: user.Nickname, + SurveyCompleted: user.SurveyCompleted, + }, nil +} + +func (s *AuthService) Login(req *LoginRequest) (*AuthResponse, error) { + user, err := s.userRepo.GetByPhone(req.Phone) + if err != nil { + return nil, errors.New("用户不存在") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + return nil, errors.New("密码错误") + } + + token, err := jwt.GenerateToken(user.ID) + if err != nil { + return nil, err + } + + return &AuthResponse{ + Token: token, + UserID: user.ID, + Nickname: user.Nickname, + SurveyCompleted: user.SurveyCompleted, + }, nil +} +``` + +### 步骤 6:创建认证 Handler + +创建 `server/internal/api/handler/auth.go`: +```go +package handler + +import ( + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService *service.AuthService +} + +func NewAuthHandler() *AuthHandler { + return &AuthHandler{ + authService: service.NewAuthService(), + } +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req service.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + result, err := h.authService.Register(&req) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, result) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req service.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + result, err := h.authService.Login(&req) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, result) +} +``` + +### 步骤 7:创建路由配置 + +创建 `server/internal/api/router.go`: +```go +package api + +import ( + "health-ai/internal/api/handler" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func SetupRouter(mode string) *gin.Engine { + gin.SetMode(mode) + r := gin.Default() + + // 跨域配置 + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + AllowCredentials: true, + })) + + // 健康检查 + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // API 路由组 + apiGroup := r.Group("/api") + { + // 认证路由(无需登录) + authHandler := handler.NewAuthHandler() + authGroup := apiGroup.Group("/auth") + { + authGroup.POST("/register", authHandler.Register) + authGroup.POST("/login", authHandler.Login) + } + } + + return r +} +``` + +### 步骤 8:更新主程序 + +更新 `server/cmd/server/main.go`,添加路由启动: +```go +// ... 前面的初始化代码 ... + +// 初始化 JWT +jwt.Init(config.AppConfig.JWT.Secret, config.AppConfig.JWT.ExpireHours) + +// 启动服务器 +router := api.SetupRouter(config.AppConfig.Server.Mode) +addr := fmt.Sprintf(":%d", config.AppConfig.Server.Port) +log.Printf("Server running on http://localhost%s", addr) +router.Run(addr) +``` + +--- + +## API 接口说明 + +### POST /api/auth/register +注册新用户 + +**请求体:** +```json +{ + "phone": "13800138000", + "password": "123456", + "nickname": "小明" +} +``` + +**响应:** +```json +{ + "code": 0, + "message": "success", + "data": { + "token": "eyJhbGc...", + "user_id": 1, + "nickname": "小明", + "survey_completed": false + } +} +``` + +### POST /api/auth/login +用户登录 + +**请求体:** +```json +{ + "phone": "13800138000", + "password": "123456" +} +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `pkg/response/response.go` | 统一响应 | +| `pkg/jwt/jwt.go` | JWT 工具 | +| `internal/api/middleware/auth.go` | 认证中间件 | +| `internal/repository/interface.go` | Repository 接口 | +| `internal/repository/impl/user.go` | 用户 Repository | +| `internal/service/auth.go` | 认证 Service | +| `internal/api/handler/auth.go` | 认证 Handler | +| `internal/api/router.go` | 路由配置 | + +--- + +## 验收标准 + +- [ ] 服务启动成功,监听 8080 端口 +- [ ] `/health` 返回 `{"status": "ok"}` +- [ ] 注册接口正常创建用户 +- [ ] 登录接口返回有效 Token +- [ ] 密码错误返回正确错误信息 + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +完成后进入 `02-后端开发/04-健康调查模块.md` diff --git a/TODOS/02-后端开发/04-健康调查模块.md b/TODOS/02-后端开发/04-健康调查模块.md new file mode 100644 index 0000000..645c70e --- /dev/null +++ b/TODOS/02-后端开发/04-健康调查模块.md @@ -0,0 +1,515 @@ +# 04-健康调查模块 + +## 目标 + +实现新用户健康调查功能,包括基础信息、生活习惯、病史、过敏史等信息的提交和管理。 + +--- + +## 前置要求 + +- 用户认证模块已完成 +- 数据模型已定义 + +--- + +## 实施步骤 + +### 步骤 1:创建健康档案 Repository + +创建 `server/internal/repository/impl/health.go`: +```go +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type HealthRepository struct{} + +func NewHealthRepository() *HealthRepository { + return &HealthRepository{} +} + +// HealthProfile +func (r *HealthRepository) CreateProfile(profile *model.HealthProfile) error { + return database.DB.Create(profile).Error +} + +func (r *HealthRepository) GetProfileByUserID(userID uint) (*model.HealthProfile, error) { + var profile model.HealthProfile + err := database.DB.Where("user_id = ?", userID).First(&profile).Error + return &profile, err +} + +func (r *HealthRepository) UpdateProfile(profile *model.HealthProfile) error { + return database.DB.Save(profile).Error +} + +// LifestyleInfo +func (r *HealthRepository) CreateLifestyle(lifestyle *model.LifestyleInfo) error { + return database.DB.Create(lifestyle).Error +} + +func (r *HealthRepository) GetLifestyleByUserID(userID uint) (*model.LifestyleInfo, error) { + var lifestyle model.LifestyleInfo + err := database.DB.Where("user_id = ?", userID).First(&lifestyle).Error + return &lifestyle, err +} + +func (r *HealthRepository) UpdateLifestyle(lifestyle *model.LifestyleInfo) error { + return database.DB.Save(lifestyle).Error +} + +// MedicalHistory +func (r *HealthRepository) CreateMedicalHistory(history *model.MedicalHistory) error { + return database.DB.Create(history).Error +} + +func (r *HealthRepository) GetMedicalHistories(profileID uint) ([]model.MedicalHistory, error) { + var histories []model.MedicalHistory + err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error + return histories, err +} + +func (r *HealthRepository) DeleteMedicalHistory(id uint) error { + return database.DB.Delete(&model.MedicalHistory{}, id).Error +} + +// FamilyHistory +func (r *HealthRepository) CreateFamilyHistory(history *model.FamilyHistory) error { + return database.DB.Create(history).Error +} + +func (r *HealthRepository) GetFamilyHistories(profileID uint) ([]model.FamilyHistory, error) { + var histories []model.FamilyHistory + err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error + return histories, err +} + +// AllergyRecord +func (r *HealthRepository) CreateAllergyRecord(record *model.AllergyRecord) error { + return database.DB.Create(record).Error +} + +func (r *HealthRepository) GetAllergyRecords(profileID uint) ([]model.AllergyRecord, error) { + var records []model.AllergyRecord + err := database.DB.Where("health_profile_id = ?", profileID).Find(&records).Error + return records, err +} +``` + +### 步骤 2:创建健康调查 Service + +创建 `server/internal/service/survey.go`: +```go +package service + +import ( + "health-ai/internal/model" + "health-ai/internal/repository/impl" +) + +type SurveyService struct { + healthRepo *impl.HealthRepository + userRepo *impl.UserRepositoryImpl +} + +func NewSurveyService() *SurveyService { + return &SurveyService{ + healthRepo: impl.NewHealthRepository(), + userRepo: impl.NewUserRepository(), + } +} + +// 基础信息请求 +type BasicInfoRequest struct { + Name string `json:"name" binding:"required"` + BirthDate string `json:"birth_date"` + Gender string `json:"gender" binding:"required,oneof=male female"` + Height float64 `json:"height" binding:"required"` + Weight float64 `json:"weight" binding:"required"` + BloodType string `json:"blood_type"` + Occupation string `json:"occupation"` + MaritalStatus string `json:"marital_status"` + Region string `json:"region"` +} + +// 生活习惯请求 +type LifestyleRequest struct { + SleepTime string `json:"sleep_time"` + WakeTime string `json:"wake_time"` + SleepQuality string `json:"sleep_quality"` + MealRegularity string `json:"meal_regularity"` + DietPreference string `json:"diet_preference"` + DailyWaterML int `json:"daily_water_ml"` + ExerciseFrequency string `json:"exercise_frequency"` + ExerciseType string `json:"exercise_type"` + ExerciseDurationMin int `json:"exercise_duration_min"` + IsSmoker bool `json:"is_smoker"` + AlcoholFrequency string `json:"alcohol_frequency"` +} + +// 病史请求 +type MedicalHistoryRequest struct { + DiseaseName string `json:"disease_name" binding:"required"` + DiseaseType string `json:"disease_type"` + DiagnosedDate string `json:"diagnosed_date"` + Status string `json:"status"` + Notes string `json:"notes"` +} + +// 家族病史请求 +type FamilyHistoryRequest struct { + Relation string `json:"relation" binding:"required"` + DiseaseName string `json:"disease_name" binding:"required"` + Notes string `json:"notes"` +} + +// 过敏记录请求 +type AllergyRequest struct { + AllergyType string `json:"allergy_type" binding:"required"` + Allergen string `json:"allergen" binding:"required"` + Severity string `json:"severity"` + ReactionDesc string `json:"reaction_desc"` +} + +// 获取调查状态 +func (s *SurveyService) GetStatus(userID uint) (map[string]bool, error) { + status := map[string]bool{ + "basic_info": false, + "lifestyle": false, + "medical_history": false, + "family_history": false, + "allergy": false, + } + + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err == nil && profile.ID > 0 { + status["basic_info"] = true + + histories, _ := s.healthRepo.GetMedicalHistories(profile.ID) + status["medical_history"] = len(histories) >= 0 // 可以为空 + + familyHistories, _ := s.healthRepo.GetFamilyHistories(profile.ID) + status["family_history"] = len(familyHistories) >= 0 + + allergies, _ := s.healthRepo.GetAllergyRecords(profile.ID) + status["allergy"] = len(allergies) >= 0 + } + + lifestyle, err := s.healthRepo.GetLifestyleByUserID(userID) + if err == nil && lifestyle.ID > 0 { + status["lifestyle"] = true + } + + return status, nil +} + +// 提交基础信息 +func (s *SurveyService) SubmitBasicInfo(userID uint, req *BasicInfoRequest) error { + // 计算 BMI + heightM := req.Height / 100 + bmi := req.Weight / (heightM * heightM) + + profile := &model.HealthProfile{ + UserID: userID, + Name: req.Name, + Gender: req.Gender, + Height: req.Height, + Weight: req.Weight, + BMI: bmi, + BloodType: req.BloodType, + Occupation: req.Occupation, + MaritalStatus: req.MaritalStatus, + Region: req.Region, + } + + // 检查是否已存在 + existing, _ := s.healthRepo.GetProfileByUserID(userID) + if existing.ID > 0 { + profile.ID = existing.ID + return s.healthRepo.UpdateProfile(profile) + } + + return s.healthRepo.CreateProfile(profile) +} + +// 提交生活习惯 +func (s *SurveyService) SubmitLifestyle(userID uint, req *LifestyleRequest) error { + lifestyle := &model.LifestyleInfo{ + UserID: userID, + SleepTime: req.SleepTime, + WakeTime: req.WakeTime, + SleepQuality: req.SleepQuality, + MealRegularity: req.MealRegularity, + DietPreference: req.DietPreference, + DailyWaterML: req.DailyWaterML, + ExerciseFrequency: req.ExerciseFrequency, + ExerciseType: req.ExerciseType, + ExerciseDurationMin: req.ExerciseDurationMin, + IsSmoker: req.IsSmoker, + AlcoholFrequency: req.AlcoholFrequency, + } + + existing, _ := s.healthRepo.GetLifestyleByUserID(userID) + if existing.ID > 0 { + lifestyle.ID = existing.ID + return s.healthRepo.UpdateLifestyle(lifestyle) + } + + return s.healthRepo.CreateLifestyle(lifestyle) +} + +// 提交病史 +func (s *SurveyService) SubmitMedicalHistory(userID uint, req *MedicalHistoryRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return err + } + + history := &model.MedicalHistory{ + HealthProfileID: profile.ID, + DiseaseName: req.DiseaseName, + DiseaseType: req.DiseaseType, + DiagnosedDate: req.DiagnosedDate, + Status: req.Status, + Notes: req.Notes, + } + + return s.healthRepo.CreateMedicalHistory(history) +} + +// 提交家族病史 +func (s *SurveyService) SubmitFamilyHistory(userID uint, req *FamilyHistoryRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return err + } + + history := &model.FamilyHistory{ + HealthProfileID: profile.ID, + Relation: req.Relation, + DiseaseName: req.DiseaseName, + Notes: req.Notes, + } + + return s.healthRepo.CreateFamilyHistory(history) +} + +// 提交过敏信息 +func (s *SurveyService) SubmitAllergy(userID uint, req *AllergyRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return err + } + + record := &model.AllergyRecord{ + HealthProfileID: profile.ID, + AllergyType: req.AllergyType, + Allergen: req.Allergen, + Severity: req.Severity, + ReactionDesc: req.ReactionDesc, + } + + return s.healthRepo.CreateAllergyRecord(record) +} + +// 标记调查完成 +func (s *SurveyService) MarkSurveyCompleted(userID uint) error { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return err + } + user.SurveyCompleted = true + return s.userRepo.Update(user) +} +``` + +### 步骤 3:创建健康调查 Handler + +创建 `server/internal/api/handler/survey.go`: +```go +package handler + +import ( + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type SurveyHandler struct { + surveyService *service.SurveyService +} + +func NewSurveyHandler() *SurveyHandler { + return &SurveyHandler{ + surveyService: service.NewSurveyService(), + } +} + +func (h *SurveyHandler) GetStatus(c *gin.Context) { + userID := middleware.GetUserID(c) + status, err := h.surveyService.GetStatus(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, status) +} + +func (h *SurveyHandler) SubmitBasicInfo(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.BasicInfoRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitBasicInfo(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, nil) +} + +func (h *SurveyHandler) SubmitLifestyle(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.LifestyleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitLifestyle(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, nil) +} + +func (h *SurveyHandler) SubmitMedicalHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.MedicalHistoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitMedicalHistory(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, nil) +} + +func (h *SurveyHandler) SubmitFamilyHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.FamilyHistoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitFamilyHistory(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, nil) +} + +func (h *SurveyHandler) SubmitAllergy(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.AllergyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitAllergy(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, nil) +} +``` + +### 步骤 4:更新路由配置 + +在 `router.go` 中添加调查路由: +```go +// 健康调查路由(需要登录) +surveyHandler := handler.NewSurveyHandler() +surveyGroup := apiGroup.Group("/survey") +surveyGroup.Use(middleware.AuthRequired()) +{ + surveyGroup.GET("/status", surveyHandler.GetStatus) + surveyGroup.POST("/basic-info", surveyHandler.SubmitBasicInfo) + surveyGroup.POST("/lifestyle", surveyHandler.SubmitLifestyle) + surveyGroup.POST("/medical-history", surveyHandler.SubmitMedicalHistory) + surveyGroup.POST("/family-history", surveyHandler.SubmitFamilyHistory) + surveyGroup.POST("/allergy", surveyHandler.SubmitAllergy) +} +``` + +--- + +## API 接口说明 + +### GET /api/survey/status +获取调查完成状态(需认证) + +### POST /api/survey/basic-info +提交基础信息 + +### POST /api/survey/lifestyle +提交生活习惯 + +### POST /api/survey/medical-history +提交病史(可多次提交) + +### POST /api/survey/family-history +提交家族病史(可多次提交) + +### POST /api/survey/allergy +提交过敏信息(可多次提交) + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `internal/repository/impl/health.go` | 健康 Repository | +| `internal/service/survey.go` | 调查 Service | +| `internal/api/handler/survey.go` | 调查 Handler | + +--- + +## 验收标准 + +- [ ] 获取调查状态接口正常 +- [ ] 基础信息提交成功,BMI 自动计算 +- [ ] 生活习惯提交成功 +- [ ] 病史、家族史、过敏信息可多次添加 +- [ ] 所有接口需要认证 + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +完成后进入 `02-后端开发/05-体质辨识模块.md` diff --git a/TODOS/02-后端开发/05-体质辨识模块.md b/TODOS/02-后端开发/05-体质辨识模块.md new file mode 100644 index 0000000..b6845a8 --- /dev/null +++ b/TODOS/02-后端开发/05-体质辨识模块.md @@ -0,0 +1,550 @@ +# 05-体质辨识模块 + +## 目标 + +实现中医体质辨识问卷功能,包括问卷题库、答案提交、体质计算和调养建议生成。 + +--- + +## 前置要求 + +- 健康调查模块已完成 +- 数据模型已定义 + +--- + +## 实施步骤 + +### 步骤 1:创建体质常量定义 + +创建 `server/internal/model/constitution_const.go`: +```go +package model + +// 九种体质类型 +const ( + ConstitutionPinghe = "pinghe" // 平和质 + ConstitutionQixu = "qixu" // 气虚质 + ConstitutionYangxu = "yangxu" // 阳虚质 + ConstitutionYinxu = "yinxu" // 阴虚质 + ConstitutionTanshi = "tanshi" // 痰湿质 + ConstitutionShire = "shire" // 湿热质 + ConstitutionXueyu = "xueyu" // 血瘀质 + ConstitutionQiyu = "qiyu" // 气郁质 + ConstitutionTebing = "tebing" // 特禀质 +) + +// 体质名称映射 +var ConstitutionNames = map[string]string{ + ConstitutionPinghe: "平和质", + ConstitutionQixu: "气虚质", + ConstitutionYangxu: "阳虚质", + ConstitutionYinxu: "阴虚质", + ConstitutionTanshi: "痰湿质", + ConstitutionShire: "湿热质", + ConstitutionXueyu: "血瘀质", + ConstitutionQiyu: "气郁质", + ConstitutionTebing: "特禀质", +} + +// 体质特征描述 +var ConstitutionDescriptions = map[string]string{ + ConstitutionPinghe: "阴阳气血调和,体态适中,面色红润,精力充沛", + ConstitutionQixu: "元气不足,容易疲劳,气短懒言,易出汗", + ConstitutionYangxu: "阳气不足,畏寒怕冷,手脚冰凉,喜热饮", + ConstitutionYinxu: "阴液亏少,口燥咽干,手足心热,盗汗", + ConstitutionTanshi: "痰湿凝聚,形体肥胖,腹部肥满,痰多", + ConstitutionShire: "湿热内蕴,面垢油光,口苦口干,大便黏滞", + ConstitutionXueyu: "血行不畅,肤色晦暗,易生斑点,健忘", + ConstitutionQiyu: "气机郁滞,情绪低落,多愁善感,胸闷", + ConstitutionTebing: "先天失常,过敏体质,易打喷嚏,皮肤易过敏", +} + +// 体质调养建议 +var ConstitutionRecommendations = map[string]map[string]string{ + ConstitutionPinghe: { + "diet": "饮食均衡,不偏食,粗细搭配", + "lifestyle": "起居有常,劳逸结合", + "exercise": "可进行各种运动,量力而行", + "emotion": "保持乐观积极的心态", + }, + ConstitutionQixu: { + "diet": "宜食益气健脾食物,如山药、大枣、小米", + "lifestyle": "避免劳累,保证充足睡眠", + "exercise": "宜柔和运动,如太极拳、散步", + "emotion": "避免过度思虑", + }, + ConstitutionYangxu: { + "diet": "宜食温阳食物,如羊肉、韭菜、生姜", + "lifestyle": "注意保暖,避免受寒", + "exercise": "宜温和运动,避免大汗", + "emotion": "保持积极乐观", + }, + ConstitutionYinxu: { + "diet": "宜食滋阴食物,如百合、银耳、枸杞", + "lifestyle": "避免熬夜,保持环境湿润", + "exercise": "宜静养,避免剧烈运动", + "emotion": "避免急躁易怒", + }, + ConstitutionTanshi: { + "diet": "饮食清淡,少食肥甘厚味,宜食薏米、冬瓜", + "lifestyle": "居住环境宜干燥通风", + "exercise": "坚持运动,促进代谢", + "emotion": "保持心情舒畅", + }, + ConstitutionShire: { + "diet": "饮食清淡,宜食苦瓜、绿豆、薏米", + "lifestyle": "避免湿热环境,保持皮肤清洁", + "exercise": "适当运动,出汗排湿", + "emotion": "保持平和心态", + }, + ConstitutionXueyu: { + "diet": "宜食活血化瘀食物,如山楂、黑木耳", + "lifestyle": "避免久坐,适当活动", + "exercise": "坚持有氧运动,促进血液循环", + "emotion": "保持心情愉快", + }, + ConstitutionQiyu: { + "diet": "宜食行气解郁食物,如玫瑰花、佛手", + "lifestyle": "多参加社交活动", + "exercise": "宜户外运动,舒展身心", + "emotion": "学会疏导情绪,培养兴趣爱好", + }, + ConstitutionTebing: { + "diet": "避免食用过敏食物,饮食清淡", + "lifestyle": "避免接触过敏原,保持环境清洁", + "exercise": "适度运动,增强体质", + "emotion": "保持心态平和", + }, +} +``` + +### 步骤 2:创建问卷题库初始化 + +创建 `server/internal/database/seed.go`: +```go +package database + +import ( + "encoding/json" + "health-ai/internal/model" +) + +// 初始化问卷题库 +func SeedQuestionBank() error { + // 检查是否已有数据 + var count int64 + DB.Model(&model.QuestionBank{}).Count(&count) + if count > 0 { + return nil + } + + questions := getQuestions() + for _, q := range questions { + if err := DB.Create(&q).Error; err != nil { + return err + } + } + return nil +} + +func getQuestions() []model.QuestionBank { + options, _ := json.Marshal([]string{"没有", "很少", "有时", "经常", "总是"}) + optStr := string(options) + + return []model.QuestionBank{ + // 平和质 (8题) + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您精力充沛吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您能适应外界自然和社会环境的变化吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易失眠吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 8}, + + // 气虚质 (8题) + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易气短(呼吸短促,接不上气)吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易心慌吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易头晕或站起时晕眩吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您喜欢安静、懒得说话吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您活动量稍大就容易出虚汗吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 8}, + + // 阳虚质 (7题) + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您手脚发凉吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您胃脘部、背部或腰膝部怕冷吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您穿的衣服总比别人多吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您吃凉东西会感到不舒服或怕吃凉东西吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您受凉或吃凉的东西后,容易拉肚子吗?", Options: optStr, OrderNum: 7}, + + // 阴虚质 (8题) + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到手脚心发热吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感觉身体、脸上发热吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您皮肤或口唇干吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您口唇的颜色比一般人红吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您容易便秘或大便干燥吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您面部两颧潮红或偏红吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到眼睛干涩吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到口干咽燥、总想喝水吗?", Options: optStr, OrderNum: 8}, + + // 痰湿质 (8题) + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到胸闷或腹部胀满吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到身体沉重不轻松或不爽快吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您腹部肥满松软吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您额头部位油脂分泌多吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您上眼睑比别人肿吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您嘴里有黏黏的感觉吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您平时痰多吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您舌苔厚腻或有舌苔厚厚的感觉吗?", Options: optStr, OrderNum: 8}, + + // 湿热质 (7题) + {ConstitutionType: model.ConstitutionShire, QuestionText: "您面部或鼻部有油腻感或油光发亮吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您脸上容易生痤疮或皮肤容易生疮疖吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您感到口苦或嘴里有异味吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您大便黏滞不爽、有解不尽的感觉吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您小便时尿道有发热感、尿色浓吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您带下色黄(白带颜色发黄)吗?(限女性回答)", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您的阴囊部位潮湿吗?(限男性回答)", Options: optStr, OrderNum: 7}, + + // 血瘀质 (7题) + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您皮肤在不知不觉中会出现青紫瘀斑吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您两颧部有细微红丝吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您身体上有哪里疼痛吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您面色晦暗或容易出现褐斑吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易有黑眼圈吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您口唇颜色偏暗吗?", Options: optStr, OrderNum: 7}, + + // 气郁质 (7题) + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您精神紧张、焦虑不安吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您多愁善感、感情脆弱吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您容易感到害怕或受到惊吓吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您胁肋部或乳房胀痛吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您无缘无故叹气吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您咽喉部有异物感吗?", Options: optStr, OrderNum: 7}, + + // 特禀质 (7题) + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会打喷嚏吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会鼻塞、流鼻涕吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您有因季节变化、温度变化或异味引起的咳嗽吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您容易过敏吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤容易起荨麻疹吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤一抓就红,并出现抓痕吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤或身上容易出现紫红色瘀点、瘀斑吗?", Options: optStr, OrderNum: 7}, + } +} +``` + +### 步骤 3:创建体质 Repository + +创建 `server/internal/repository/impl/constitution.go`: +```go +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type ConstitutionRepository struct{} + +func NewConstitutionRepository() *ConstitutionRepository { + return &ConstitutionRepository{} +} + +func (r *ConstitutionRepository) GetQuestions() ([]model.QuestionBank, error) { + var questions []model.QuestionBank + err := database.DB.Order("constitution_type, order_num").Find(&questions).Error + return questions, err +} + +func (r *ConstitutionRepository) CreateAssessment(assessment *model.ConstitutionAssessment) error { + return database.DB.Create(assessment).Error +} + +func (r *ConstitutionRepository) GetLatestAssessment(userID uint) (*model.ConstitutionAssessment, error) { + var assessment model.ConstitutionAssessment + err := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC").First(&assessment).Error + return &assessment, err +} + +func (r *ConstitutionRepository) GetAssessmentHistory(userID uint) ([]model.ConstitutionAssessment, error) { + var assessments []model.ConstitutionAssessment + err := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC").Find(&assessments).Error + return assessments, err +} +``` + +### 步骤 4:创建体质计算 Service + +创建 `server/internal/service/constitution.go`: +```go +package service + +import ( + "encoding/json" + "sort" + "time" + + "health-ai/internal/model" + "health-ai/internal/repository/impl" +) + +type ConstitutionService struct { + repo *impl.ConstitutionRepository +} + +func NewConstitutionService() *ConstitutionService { + return &ConstitutionService{ + repo: impl.NewConstitutionRepository(), + } +} + +type AnswerRequest struct { + QuestionID uint `json:"question_id" binding:"required"` + Score int `json:"score" binding:"required,min=1,max=5"` +} + +type SubmitRequest struct { + Answers []AnswerRequest `json:"answers" binding:"required"` +} + +type ConstitutionScore struct { + Type string `json:"type"` + Name string `json:"name"` + Score float64 `json:"score"` + Description string `json:"description"` +} + +type AssessmentResult struct { + PrimaryConstitution ConstitutionScore `json:"primary_constitution"` + SecondaryConstitutions []ConstitutionScore `json:"secondary_constitutions"` + AllScores []ConstitutionScore `json:"all_scores"` + Recommendations map[string]map[string]string `json:"recommendations"` + AssessedAt time.Time `json:"assessed_at"` +} + +func (s *ConstitutionService) GetQuestions() ([]model.QuestionBank, error) { + return s.repo.GetQuestions() +} + +func (s *ConstitutionService) SubmitAssessment(userID uint, req *SubmitRequest) (*AssessmentResult, error) { + // 获取所有问题 + questions, err := s.repo.GetQuestions() + if err != nil { + return nil, err + } + + // 构建问题ID到体质类型的映射 + questionTypeMap := make(map[uint]string) + typeQuestionCount := make(map[string]int) + for _, q := range questions { + questionTypeMap[q.ID] = q.ConstitutionType + typeQuestionCount[q.ConstitutionType]++ + } + + // 计算各体质原始分 + typeScores := make(map[string]int) + for _, answer := range req.Answers { + if cType, ok := questionTypeMap[answer.QuestionID]; ok { + typeScores[cType] += answer.Score + } + } + + // 计算转化分 + allScores := make([]ConstitutionScore, 0) + for cType, rawScore := range typeScores { + questionCount := typeQuestionCount[cType] + if questionCount == 0 { + continue + } + // 转化分 = (原始分 - 条目数) / (条目数 × 4) × 100 + transformedScore := float64(rawScore-questionCount) / float64(questionCount*4) * 100 + + allScores = append(allScores, ConstitutionScore{ + Type: cType, + Name: model.ConstitutionNames[cType], + Score: transformedScore, + Description: model.ConstitutionDescriptions[cType], + }) + } + + // 按分数排序 + sort.Slice(allScores, func(i, j int) bool { + return allScores[i].Score > allScores[j].Score + }) + + // 判定主要体质和次要体质 + var primary ConstitutionScore + var secondary []ConstitutionScore + + // 平和质特殊判定 + pingheScore := float64(0) + otherMax := float64(0) + for _, score := range allScores { + if score.Type == model.ConstitutionPinghe { + pingheScore = score.Score + } else if score.Score > otherMax { + otherMax = score.Score + } + } + + if pingheScore >= 60 && otherMax < 30 { + // 判定为平和质 + for _, score := range allScores { + if score.Type == model.ConstitutionPinghe { + primary = score + break + } + } + } else { + // 判定为偏颇体质 + for _, score := range allScores { + if score.Type == model.ConstitutionPinghe { + continue + } + if primary.Type == "" && score.Score >= 40 { + primary = score + } else if score.Score >= 30 { + secondary = append(secondary, score) + } + } + // 如果没有≥40的,取最高分 + if primary.Type == "" && len(allScores) > 0 { + for _, score := range allScores { + if score.Type != model.ConstitutionPinghe { + primary = score + break + } + } + } + } + + // 获取调养建议 + recommendations := make(map[string]map[string]string) + recommendations[primary.Type] = model.ConstitutionRecommendations[primary.Type] + for _, sec := range secondary { + recommendations[sec.Type] = model.ConstitutionRecommendations[sec.Type] + } + + // 保存评估结果 + scoresJSON, _ := json.Marshal(allScores) + secondaryJSON, _ := json.Marshal(secondary) + recsJSON, _ := json.Marshal(recommendations) + + assessment := &model.ConstitutionAssessment{ + UserID: userID, + AssessedAt: time.Now(), + Scores: string(scoresJSON), + PrimaryConstitution: primary.Type, + SecondaryConstitutions: string(secondaryJSON), + Recommendations: string(recsJSON), + } + if err := s.repo.CreateAssessment(assessment); err != nil { + return nil, err + } + + return &AssessmentResult{ + PrimaryConstitution: primary, + SecondaryConstitutions: secondary, + AllScores: allScores, + Recommendations: recommendations, + AssessedAt: assessment.AssessedAt, + }, nil +} + +func (s *ConstitutionService) GetLatestResult(userID uint) (*AssessmentResult, error) { + assessment, err := s.repo.GetLatestAssessment(userID) + if err != nil { + return nil, err + } + + var allScores []ConstitutionScore + var secondary []ConstitutionScore + var recommendations map[string]map[string]string + + json.Unmarshal([]byte(assessment.Scores), &allScores) + json.Unmarshal([]byte(assessment.SecondaryConstitutions), &secondary) + json.Unmarshal([]byte(assessment.Recommendations), &recommendations) + + var primary ConstitutionScore + for _, score := range allScores { + if score.Type == assessment.PrimaryConstitution { + primary = score + break + } + } + + return &AssessmentResult{ + PrimaryConstitution: primary, + SecondaryConstitutions: secondary, + AllScores: allScores, + Recommendations: recommendations, + AssessedAt: assessment.AssessedAt, + }, nil +} + +func (s *ConstitutionService) GetHistory(userID uint) ([]model.ConstitutionAssessment, error) { + return s.repo.GetAssessmentHistory(userID) +} +``` + +### 步骤 5:创建体质 Handler 和更新路由 + +创建 Handler 并在 router.go 中注册路由。 + +--- + +## API 接口说明 + +### GET /api/constitution/questions +获取体质问卷题目 + +### POST /api/constitution/submit +提交问卷答案,返回体质辨识结果 + +### GET /api/constitution/result +获取最新体质辨识结果 + +### GET /api/constitution/history +获取体质测评历史 + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `internal/model/constitution_const.go` | 体质常量定义 | +| `internal/database/seed.go` | 问卷题库初始化 | +| `internal/repository/impl/constitution.go` | 体质 Repository | +| `internal/service/constitution.go` | 体质计算 Service | +| `internal/api/handler/constitution.go` | 体质 Handler | + +--- + +## 验收标准 + +- [ ] 问卷题库自动初始化(67题) +- [ ] 获取问卷接口返回所有题目 +- [ ] 提交答案后正确计算体质得分 +- [ ] 体质判定逻辑正确(平和质特殊判定) +- [ ] 调养建议正确返回 + +--- + +## 预计耗时 + +40-50 分钟 + +--- + +## 下一步 + +完成后进入 `02-后端开发/06-AI对话模块.md` diff --git a/TODOS/02-后端开发/06-AI对话模块.md b/TODOS/02-后端开发/06-AI对话模块.md new file mode 100644 index 0000000..35f5468 --- /dev/null +++ b/TODOS/02-后端开发/06-AI对话模块.md @@ -0,0 +1,833 @@ +# 06-AI对话模块 + +## 目标 + +实现 AI 健康问诊对话功能,支持多轮对话、结合用户体质信息、流式响应。 + +--- + +## 前置要求 + +- 体质辨识模块已完成 +- 已有 AI API Key(OpenAI / 通义千问) + +--- + +## 实施步骤 + +### 步骤 1:创建 AI 客户端抽象 + +创建 `server/internal/service/ai/client.go`: +```go +package ai + +import ( + "context" + "io" +) + +// AIClient AI 客户端接口 +type AIClient interface { + Chat(ctx context.Context, messages []Message) (string, error) + ChatStream(ctx context.Context, messages []Message, writer io.Writer) error +} + +// Message 对话消息 +type Message struct { + Role string `json:"role"` // system, user, assistant + Content string `json:"content"` +} + +// Config AI 配置 +type Config struct { + Provider string + APIKey string + BaseURL string + Model string +} +``` + +### 步骤 2:实现 OpenAI 客户端 + +创建 `server/internal/service/ai/openai.go`: +```go +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type OpenAIClient struct { + apiKey string + baseURL string + model string +} + +func NewOpenAIClient(cfg *Config) *OpenAIClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + model := cfg.Model + if model == "" { + model = "gpt-3.5-turbo" + } + return &OpenAIClient{ + apiKey: cfg.APIKey, + baseURL: baseURL, + model: model, + } +} + +type openAIRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` +} + +type openAIResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` +} + +func (c *OpenAIClient) Chat(ctx context.Context, messages []Message) (string, error) { + reqBody := openAIRequest{ + Model: c.model, + Messages: messages, + Stream: false, + } + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result openAIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("no response from AI") + } + + return result.Choices[0].Message.Content, nil +} + +func (c *OpenAIClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error { + reqBody := openAIRequest{ + Model: c.model, + Messages: messages, + Stream: true, + } + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // 读取 SSE 流 + reader := resp.Body + buf := make([]byte, 1024) + for { + n, err := reader.Read(buf) + if err == io.EOF { + break + } + if err != nil { + return err + } + writer.Write(buf[:n]) + } + + return nil +} +``` + +### 步骤 2.5:实现阿里云通义千问客户端 + +创建 `server/internal/service/ai/aliyun.go`: +```go +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const AliyunBaseURL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation" + +type AliyunClient struct { + apiKey string + model string +} + +func NewAliyunClient(cfg *Config) *AliyunClient { + model := cfg.Model + if model == "" { + model = "qwen-turbo" + } + return &AliyunClient{ + apiKey: cfg.APIKey, + model: model, + } +} + +type aliyunRequest struct { + Model string `json:"model"` + Input struct { + Messages []Message `json:"messages"` + } `json:"input"` + Parameters struct { + ResultFormat string `json:"result_format"` + MaxTokens int `json:"max_tokens,omitempty"` + } `json:"parameters"` +} + +type aliyunResponse struct { + Output struct { + Text string `json:"text"` + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } `json:"output"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (c *AliyunClient) Chat(ctx context.Context, messages []Message) (string, error) { + reqBody := aliyunRequest{ + Model: c.model, + } + reqBody.Input.Messages = messages + reqBody.Parameters.ResultFormat = "message" + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result aliyunResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if result.Code != "" { + return "", fmt.Errorf("aliyun API error: %s - %s", result.Code, result.Message) + } + + // 兼容两种返回格式 + if len(result.Output.Choices) > 0 { + return result.Output.Choices[0].Message.Content, nil + } + if result.Output.Text != "" { + return result.Output.Text, nil + } + + return "", fmt.Errorf("no response from AI") +} + +func (c *AliyunClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error { + reqBody := aliyunRequest{ + Model: c.model, + } + reqBody.Input.Messages = messages + reqBody.Parameters.ResultFormat = "message" + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("X-DashScope-SSE", "enable") // 启用流式输出 + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // 读取 SSE 流 + buf := make([]byte, 1024) + for { + n, err := resp.Body.Read(buf) + if err == io.EOF { + break + } + if err != nil { + return err + } + writer.Write(buf[:n]) + } + + return nil +} +``` + +### 步骤 2.6:创建 AI 客户端工厂 + +创建 `server/internal/service/ai/factory.go`: +```go +package ai + +import "health-ai/internal/config" + +// NewAIClient 根据配置创建 AI 客户端 +func NewAIClient(cfg *config.AIConfig) AIClient { + switch cfg.Provider { + case "aliyun": + return NewAliyunClient(&Config{ + APIKey: cfg.Aliyun.APIKey, + Model: cfg.Aliyun.Model, + }) + case "openai": + fallthrough + default: + return NewOpenAIClient(&Config{ + APIKey: cfg.OpenAI.APIKey, + BaseURL: cfg.OpenAI.BaseURL, + Model: cfg.OpenAI.Model, + }) + } +} +``` + +### 步骤 3:创建对话 Repository + +创建 `server/internal/repository/impl/conversation.go`: +```go +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type ConversationRepository struct{} + +func NewConversationRepository() *ConversationRepository { + return &ConversationRepository{} +} + +func (r *ConversationRepository) Create(conv *model.Conversation) error { + return database.DB.Create(conv).Error +} + +func (r *ConversationRepository) GetByID(id uint) (*model.Conversation, error) { + var conv model.Conversation + err := database.DB.Preload("Messages").First(&conv, id).Error + return &conv, err +} + +func (r *ConversationRepository) GetByUserID(userID uint) ([]model.Conversation, error) { + var convs []model.Conversation + err := database.DB.Where("user_id = ?", userID).Order("updated_at DESC").Find(&convs).Error + return convs, err +} + +func (r *ConversationRepository) Delete(id uint) error { + // 先删除消息 + database.DB.Where("conversation_id = ?", id).Delete(&model.Message{}) + return database.DB.Delete(&model.Conversation{}, id).Error +} + +func (r *ConversationRepository) AddMessage(msg *model.Message) error { + return database.DB.Create(msg).Error +} + +func (r *ConversationRepository) GetMessages(convID uint) ([]model.Message, error) { + var messages []model.Message + err := database.DB.Where("conversation_id = ?", convID).Order("created_at ASC").Find(&messages).Error + return messages, err +} + +func (r *ConversationRepository) UpdateTitle(id uint, title string) error { + return database.DB.Model(&model.Conversation{}).Where("id = ?", id).Update("title", title).Error +} +``` + +### 步骤 4:创建对话 Service + +创建 `server/internal/service/conversation.go`: +```go +package service + +import ( + "context" + "fmt" + "io" + "time" + + "health-ai/internal/config" + "health-ai/internal/model" + "health-ai/internal/repository/impl" + "health-ai/internal/service/ai" +) + +type ConversationService struct { + convRepo *impl.ConversationRepository + constitutionRepo *impl.ConstitutionRepository + healthRepo *impl.HealthRepository + aiClient ai.AIClient +} + +func NewConversationService() *ConversationService { + // 使用工厂方法创建 AI 客户端 + client := ai.NewAIClient(&config.AppConfig.AI) + + return &ConversationService{ + convRepo: impl.NewConversationRepository(), + constitutionRepo: impl.NewConstitutionRepository(), + healthRepo: impl.NewHealthRepository(), + aiClient: client, + } +} + +// 获取用户对话列表 +func (s *ConversationService) GetConversations(userID uint) ([]model.Conversation, error) { + return s.convRepo.GetByUserID(userID) +} + +// 创建新对话 +func (s *ConversationService) CreateConversation(userID uint, title string) (*model.Conversation, error) { + if title == "" { + title = "新对话 " + time.Now().Format("01-02 15:04") + } + conv := &model.Conversation{ + UserID: userID, + Title: title, + } + if err := s.convRepo.Create(conv); err != nil { + return nil, err + } + return conv, nil +} + +// 获取对话详情 +func (s *ConversationService) GetConversation(id uint) (*model.Conversation, error) { + return s.convRepo.GetByID(id) +} + +// 删除对话 +func (s *ConversationService) DeleteConversation(id uint) error { + return s.convRepo.Delete(id) +} + +// 发送消息 +func (s *ConversationService) SendMessage(ctx context.Context, userID uint, convID uint, content string) (string, error) { + // 保存用户消息 + userMsg := &model.Message{ + ConversationID: convID, + Role: "user", + Content: content, + } + if err := s.convRepo.AddMessage(userMsg); err != nil { + return "", err + } + + // 构建对话上下文 + messages := s.buildMessages(userID, convID, content) + + // 调用 AI + response, err := s.aiClient.Chat(ctx, messages) + if err != nil { + return "", err + } + + // 保存 AI 回复 + assistantMsg := &model.Message{ + ConversationID: convID, + Role: "assistant", + Content: response, + } + if err := s.convRepo.AddMessage(assistantMsg); err != nil { + return "", err + } + + return response, nil +} + +// 流式发送消息 +func (s *ConversationService) SendMessageStream(ctx context.Context, userID uint, convID uint, content string, writer io.Writer) error { + // 保存用户消息 + userMsg := &model.Message{ + ConversationID: convID, + Role: "user", + Content: content, + } + if err := s.convRepo.AddMessage(userMsg); err != nil { + return err + } + + // 构建对话上下文 + messages := s.buildMessages(userID, convID, content) + + // 调用 AI 流式接口 + return s.aiClient.ChatStream(ctx, messages, writer) +} + +// 构建消息上下文 +func (s *ConversationService) buildMessages(userID uint, convID uint, currentMsg string) []ai.Message { + messages := []ai.Message{} + + // 系统提示词 + systemPrompt := s.buildSystemPrompt(userID) + messages = append(messages, ai.Message{ + Role: "system", + Content: systemPrompt, + }) + + // 历史消息(限制数量避免超出 token 限制) + historyMsgs, _ := s.convRepo.GetMessages(convID) + + // 限制历史消息数量 + maxHistory := config.AppConfig.AI.MaxHistoryMessages + if maxHistory <= 0 { + maxHistory = 10 // 默认10条 + } + if len(historyMsgs) > maxHistory { + historyMsgs = historyMsgs[len(historyMsgs)-maxHistory:] + } + + for _, msg := range historyMsgs { + messages = append(messages, ai.Message{ + Role: msg.Role, + Content: msg.Content, + }) + } + + return messages +} + +// 系统提示词模板(完整版见 design.md 4.3.1 节) +const systemPromptTemplate = `# 角色定义 +你是"健康AI助手",一个专业的健康咨询助理。你基于中医体质辨识理论,为用户提供个性化的健康建议。 + +## 重要声明 +- 你不是专业医师,仅提供健康咨询和养生建议 +- 你的建议不能替代医生的诊断和治疗 +- 遇到以下情况,必须立即建议用户就医: + * 胸痛、呼吸困难、剧烈头痛 + * 高烧不退(超过39°C持续24小时) + * 意识模糊、晕厥 + * 严重外伤、大量出血 + * 持续剧烈腹痛 + * 疑似中风症状(口眼歪斜、肢体无力、言语不清) + +## 用户信息 +%s + +## 用户体质 +%s + +## 用药历史 +%s + +## 回答原则 +1. 回答控制在200字以内,简洁明了 +2. 根据用户体质给出针对性建议 +3. 用药建议优先推荐非处方中成药或食疗,注明"建议咨询药师" +4. 不推荐处方药,不做疾病诊断 + +## 回答格式 +【情况分析】一句话概括 +【建议】 +1. 具体建议 +【用药参考】(如适用) +- 药品名称:用法(建议咨询药师) +【提醒】注意事项或就医建议` + +// 构建系统提示词(包含用户体质信息) +func (s *ConversationService) buildSystemPrompt(userID uint) string { + var userProfile, constitutionInfo, medicationHistory string + + // 获取用户基本信息 + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err == nil && profile.ID > 0 { + age := calculateAge(profile.BirthDate) + gender := "未知" + if profile.Gender == "male" { + gender = "男" + } else if profile.Gender == "female" { + gender = "女" + } + userProfile = fmt.Sprintf("性别:%s,年龄:%d岁,BMI:%.1f", gender, age, profile.BMI) + } else { + userProfile = "暂无" + } + + // 获取用户体质信息 + constitution, err := s.constitutionRepo.GetLatestAssessment(userID) + if err == nil && constitution.ID > 0 { + constitutionName := model.ConstitutionNames[constitution.PrimaryConstitution] + description := model.ConstitutionDescriptions[constitution.PrimaryConstitution] + constitutionInfo = fmt.Sprintf("主体质:%s\n特征:%s", constitutionName, description) + } else { + constitutionInfo = "暂未测评" + } + + // 获取用药历史 + medications, err := s.healthRepo.GetMedications(userID) + if err == nil && len(medications) > 0 { + var medNames []string + for _, m := range medications { + medNames = append(medNames, m.Name) + } + medicationHistory = "近期用药:" + strings.Join(medNames, "、") + } else { + medicationHistory = "暂无记录" + } + + return fmt.Sprintf(systemPromptTemplate, userProfile, constitutionInfo, medicationHistory) + + // 获取用户健康档案 + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err == nil && profile.ID > 0 { + basePrompt += fmt.Sprintf(` + +用户基本信息: +- 性别:%s +- BMI:%.1f`, profile.Gender, profile.BMI) + } + + return basePrompt +} +``` + +### 步骤 5:创建对话 Handler + +创建 `server/internal/api/handler/conversation.go`: +```go +package handler + +import ( + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type ConversationHandler struct { + convService *service.ConversationService +} + +func NewConversationHandler() *ConversationHandler { + return &ConversationHandler{ + convService: service.NewConversationService(), + } +} + +func (h *ConversationHandler) GetConversations(c *gin.Context) { + userID := middleware.GetUserID(c) + convs, err := h.convService.GetConversations(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, convs) +} + +func (h *ConversationHandler) CreateConversation(c *gin.Context) { + userID := middleware.GetUserID(c) + var req struct { + Title string `json:"title"` + } + c.ShouldBindJSON(&req) + + conv, err := h.convService.CreateConversation(userID, req.Title) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, conv) +} + +func (h *ConversationHandler) GetConversation(c *gin.Context) { + id := c.Param("id") + var convID uint + fmt.Sscanf(id, "%d", &convID) + + conv, err := h.convService.GetConversation(convID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, conv) +} + +func (h *ConversationHandler) DeleteConversation(c *gin.Context) { + id := c.Param("id") + var convID uint + fmt.Sscanf(id, "%d", &convID) + + if err := h.convService.DeleteConversation(convID); err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, nil) +} + +type SendMessageRequest struct { + Content string `json:"content" binding:"required"` +} + +func (h *ConversationHandler) SendMessage(c *gin.Context) { + userID := middleware.GetUserID(c) + id := c.Param("id") + var convID uint + fmt.Sscanf(id, "%d", &convID) + + var req SendMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + reply, err := h.convService.SendMessage(c.Request.Context(), userID, convID, req.Content) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, gin.H{ + "reply": reply, + }) +} +``` + +### 步骤 6:更新路由 + +在 `router.go` 中添加对话路由。 + +--- + +## API 接口说明 + +### GET /api/conversations +获取对话列表 + +### POST /api/conversations +创建新对话 + +### GET /api/conversations/:id +获取对话详情(含消息) + +### DELETE /api/conversations/:id +删除对话 + +### POST /api/conversations/:id/messages +发送消息并获取 AI 回复 + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `internal/service/ai/client.go` | AI 客户端接口定义 | +| `internal/service/ai/openai.go` | OpenAI 客户端实现 | +| `internal/service/ai/aliyun.go` | 阿里云通义千问客户端实现 | +| `internal/service/ai/factory.go` | AI 客户端工厂 | +| `internal/repository/impl/conversation.go` | 对话 Repository | +| `internal/service/conversation.go` | 对话 Service | +| `internal/api/handler/conversation.go` | 对话 Handler | + +--- + +## AI 服务配置说明 + +在 `config.yaml` 中配置 AI 服务: + +```yaml +ai: + provider: aliyun # 可选: openai, aliyun + max_history_messages: 10 # 最大历史消息数 + + openai: + api_key: "sk-xxx" # OpenAI API Key + base_url: "https://api.openai.com/v1" + model: "gpt-3.5-turbo" + + aliyun: + api_key: "sk-xxx" # 阿里云 DashScope API Key + model: "qwen-turbo" # 可选: qwen-turbo, qwen-plus, qwen-max +``` + +--- + +## 验收标准 + +- [ ] 创建/获取/删除对话正常 +- [ ] 发送消息返回 AI 回复 +- [ ] AI 回复结合用户体质和用药历史 +- [ ] 对话历史正确保存(限制消息数量) +- [ ] 支持 OpenAI 和阿里云通义千问切换 +- [ ] 无 API Key 时给出友好提示 +- [ ] 紧急情况提示用户就医 + +--- + +## 预计耗时 + +40-50 分钟 + +--- + +## 下一步 + +完成后进入 `02-后端开发/07-健康档案模块.md` diff --git a/TODOS/02-后端开发/07-健康档案模块.md b/TODOS/02-后端开发/07-健康档案模块.md new file mode 100644 index 0000000..dcef11f --- /dev/null +++ b/TODOS/02-后端开发/07-健康档案模块.md @@ -0,0 +1,512 @@ +# 07-健康档案模块 + +## 目标 + +实现用户健康档案的查询和管理功能,提供完整的健康信息视图。 + +--- + +## 前置要求 + +- 健康调查模块已完成 +- 体质辨识模块已完成 + +--- + +## 实施步骤 + +### 步骤 1:创建用户 Service + +创建 `server/internal/service/user.go`: +```go +package service + +import ( + "health-ai/internal/model" + "health-ai/internal/repository/impl" +) + +type UserService struct { + userRepo *impl.UserRepositoryImpl + healthRepo *impl.HealthRepository + constitutionRepo *impl.ConstitutionRepository +} + +func NewUserService() *UserService { + return &UserService{ + userRepo: impl.NewUserRepository(), + healthRepo: impl.NewHealthRepository(), + constitutionRepo: impl.NewConstitutionRepository(), + } +} + +// 用户资料响应 +type UserProfileResponse struct { + ID uint `json:"id"` + Phone string `json:"phone"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + SurveyCompleted bool `json:"survey_completed"` +} + +// 获取用户资料 +func (s *UserService) GetProfile(userID uint) (*UserProfileResponse, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, err + } + return &UserProfileResponse{ + ID: user.ID, + Phone: user.Phone, + Email: user.Email, + Nickname: user.Nickname, + Avatar: user.Avatar, + SurveyCompleted: user.SurveyCompleted, + }, nil +} + +// 更新用户资料请求 +type UpdateProfileRequest struct { + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Email string `json:"email"` +} + +// 更新用户资料 +func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileRequest) error { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return err + } + if req.Nickname != "" { + user.Nickname = req.Nickname + } + if req.Avatar != "" { + user.Avatar = req.Avatar + } + if req.Email != "" { + user.Email = req.Email + } + return s.userRepo.Update(user) +} + +// 完整健康档案响应 +type FullHealthProfileResponse struct { + BasicInfo *model.HealthProfile `json:"basic_info"` + Lifestyle *model.LifestyleInfo `json:"lifestyle"` + MedicalHistory []model.MedicalHistory `json:"medical_history"` + FamilyHistory []model.FamilyHistory `json:"family_history"` + AllergyRecords []model.AllergyRecord `json:"allergy_records"` + Constitution *ConstitutionSummary `json:"constitution"` +} + +type ConstitutionSummary struct { + PrimaryType string `json:"primary_type"` + PrimaryName string `json:"primary_name"` + PrimaryDescription string `json:"primary_description"` + AssessedAt string `json:"assessed_at"` +} + +// 获取完整健康档案 +func (s *UserService) GetFullHealthProfile(userID uint) (*FullHealthProfileResponse, error) { + response := &FullHealthProfileResponse{} + + // 基础信息 + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err == nil && profile.ID > 0 { + response.BasicInfo = profile + + // 病史 + histories, _ := s.healthRepo.GetMedicalHistories(profile.ID) + response.MedicalHistory = histories + + // 家族史 + familyHistories, _ := s.healthRepo.GetFamilyHistories(profile.ID) + response.FamilyHistory = familyHistories + + // 过敏记录 + allergies, _ := s.healthRepo.GetAllergyRecords(profile.ID) + response.AllergyRecords = allergies + } + + // 生活习惯 + lifestyle, err := s.healthRepo.GetLifestyleByUserID(userID) + if err == nil && lifestyle.ID > 0 { + response.Lifestyle = lifestyle + } + + // 体质信息 + constitution, err := s.constitutionRepo.GetLatestAssessment(userID) + if err == nil && constitution.ID > 0 { + response.Constitution = &ConstitutionSummary{ + PrimaryType: constitution.PrimaryConstitution, + PrimaryName: model.ConstitutionNames[constitution.PrimaryConstitution], + PrimaryDescription: model.ConstitutionDescriptions[constitution.PrimaryConstitution], + AssessedAt: constitution.AssessedAt.Format("2006-01-02"), + } + } + + return response, nil +} + +// 更新健康档案基础信息 +func (s *UserService) UpdateHealthProfile(userID uint, req *BasicInfoRequest) error { + return NewSurveyService().SubmitBasicInfo(userID, req) +} + +// 更新生活习惯 +func (s *UserService) UpdateLifestyle(userID uint, req *LifestyleRequest) error { + return NewSurveyService().SubmitLifestyle(userID, req) +} +``` + +### 步骤 2:创建用户 Handler + +创建 `server/internal/api/handler/user.go`: +```go +package handler + +import ( + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type UserHandler struct { + userService *service.UserService +} + +func NewUserHandler() *UserHandler { + return &UserHandler{ + userService: service.NewUserService(), + } +} + +// 获取用户资料 +func (h *UserHandler) GetProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + profile, err := h.userService.GetProfile(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, profile) +} + +// 更新用户资料 +func (h *UserHandler) UpdateProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.userService.UpdateProfile(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, nil) +} + +// 获取完整健康档案 +func (h *UserHandler) GetHealthProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + profile, err := h.userService.GetFullHealthProfile(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, profile) +} + +// 更新健康档案基础信息 +func (h *UserHandler) UpdateHealthProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.BasicInfoRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.userService.UpdateHealthProfile(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, nil) +} + +// 获取生活习惯 +func (h *UserHandler) GetLifestyle(c *gin.Context) { + userID := middleware.GetUserID(c) + profile, err := h.userService.GetFullHealthProfile(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, profile.Lifestyle) +} + +// 更新生活习惯 +func (h *UserHandler) UpdateLifestyle(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.LifestyleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := h.userService.UpdateLifestyle(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, nil) +} +``` + +### 步骤 3:更新完整路由配置 + +更新 `server/internal/api/router.go`: +```go +package api + +import ( + "health-ai/internal/api/handler" + "health-ai/internal/api/middleware" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func SetupRouter(mode string) *gin.Engine { + gin.SetMode(mode) + r := gin.Default() + + // 跨域配置 + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + AllowCredentials: true, + })) + + // 健康检查 + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // API 路由组 + api := r.Group("/api") + { + // ========== 公开接口(无需登录)========== + authHandler := handler.NewAuthHandler() + auth := api.Group("/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + } + + // ========== 需要认证的接口 ========== + authorized := api.Group("") + authorized.Use(middleware.AuthRequired()) + { + // 用户接口 + userHandler := handler.NewUserHandler() + user := authorized.Group("/user") + { + user.GET("/profile", userHandler.GetProfile) + user.PUT("/profile", userHandler.UpdateProfile) + user.GET("/health-profile", userHandler.GetHealthProfile) + user.PUT("/health-profile", userHandler.UpdateHealthProfile) + user.GET("/lifestyle", userHandler.GetLifestyle) + user.PUT("/lifestyle", userHandler.UpdateLifestyle) + } + + // 健康调查接口 + surveyHandler := handler.NewSurveyHandler() + survey := authorized.Group("/survey") + { + survey.GET("/status", surveyHandler.GetStatus) + survey.POST("/basic-info", surveyHandler.SubmitBasicInfo) + survey.POST("/lifestyle", surveyHandler.SubmitLifestyle) + survey.POST("/medical-history", surveyHandler.SubmitMedicalHistory) + survey.POST("/family-history", surveyHandler.SubmitFamilyHistory) + survey.POST("/allergy", surveyHandler.SubmitAllergy) + } + + // 体质辨识接口 + constitutionHandler := handler.NewConstitutionHandler() + constitution := authorized.Group("/constitution") + { + constitution.GET("/questions", constitutionHandler.GetQuestions) + constitution.POST("/submit", constitutionHandler.Submit) + constitution.GET("/result", constitutionHandler.GetResult) + constitution.GET("/history", constitutionHandler.GetHistory) + constitution.GET("/recommendations", constitutionHandler.GetRecommendations) + } + + // 对话接口 + convHandler := handler.NewConversationHandler() + conversations := authorized.Group("/conversations") + { + conversations.GET("", convHandler.GetConversations) + conversations.POST("", convHandler.CreateConversation) + conversations.GET("/:id", convHandler.GetConversation) + conversations.DELETE("/:id", convHandler.DeleteConversation) + conversations.POST("/:id/messages", convHandler.SendMessage) + } + } + } + + return r +} +``` + +### 步骤 4:更新主程序完整版 + +更新 `server/cmd/server/main.go`: +```go +package main + +import ( + "fmt" + "log" + + "health-ai/internal/api" + "health-ai/internal/config" + "health-ai/internal/database" + "health-ai/internal/model" + "health-ai/pkg/jwt" +) + +func main() { + // 加载配置 + if err := config.LoadConfig("config.yaml"); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + log.Println("✓ Config loaded") + + // 初始化数据库 + if err := database.InitDatabase(&config.AppConfig.Database); err != nil { + log.Fatalf("Failed to init database: %v", err) + } + log.Println("✓ Database connected") + + // 自动迁移 + if err := database.AutoMigrate(model.AllModels()...); err != nil { + log.Fatalf("Failed to migrate: %v", err) + } + log.Println("✓ Database migrated") + + // 初始化问卷题库 + if err := database.SeedQuestionBank(); err != nil { + log.Fatalf("Failed to seed question bank: %v", err) + } + log.Println("✓ Question bank seeded") + + // 初始化 JWT + jwt.Init(config.AppConfig.JWT.Secret, config.AppConfig.JWT.ExpireHours) + log.Println("✓ JWT initialized") + + // 启动服务器 + router := api.SetupRouter(config.AppConfig.Server.Mode) + addr := fmt.Sprintf(":%d", config.AppConfig.Server.Port) + log.Printf("✓ Server running on http://localhost%s", addr) + log.Println("========================================") + log.Println("Health AI Server Ready!") + log.Println("========================================") + + if err := router.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} +``` + +--- + +## API 接口说明 + +### GET /api/user/profile +获取用户基本资料 + +### PUT /api/user/profile +更新用户基本资料(昵称、头像、邮箱) + +### GET /api/user/health-profile +获取完整健康档案(含基础信息、生活习惯、病史、体质) + +### PUT /api/user/health-profile +更新健康档案基础信息 + +### GET /api/user/lifestyle +获取生活习惯信息 + +### PUT /api/user/lifestyle +更新生活习惯信息 + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `internal/service/user.go` | 用户 Service | +| `internal/api/handler/user.go` | 用户 Handler | +| 更新 `internal/api/router.go` | 完整路由配置 | +| 更新 `cmd/server/main.go` | 完整主程序 | + +--- + +## 完整 API 列表汇总 + +| 方法 | 路径 | 说明 | 认证 | +|------|------|------|------| +| GET | /health | 健康检查 | 否 | +| POST | /api/auth/register | 用户注册 | 否 | +| POST | /api/auth/login | 用户登录 | 否 | +| GET | /api/user/profile | 获取用户资料 | 是 | +| PUT | /api/user/profile | 更新用户资料 | 是 | +| GET | /api/user/health-profile | 获取健康档案 | 是 | +| PUT | /api/user/health-profile | 更新健康档案 | 是 | +| GET | /api/user/lifestyle | 获取生活习惯 | 是 | +| PUT | /api/user/lifestyle | 更新生活习惯 | 是 | +| GET | /api/survey/status | 获取调查状态 | 是 | +| POST | /api/survey/basic-info | 提交基础信息 | 是 | +| POST | /api/survey/lifestyle | 提交生活习惯 | 是 | +| POST | /api/survey/medical-history | 提交病史 | 是 | +| POST | /api/survey/family-history | 提交家族史 | 是 | +| POST | /api/survey/allergy | 提交过敏信息 | 是 | +| GET | /api/constitution/questions | 获取问卷题目 | 是 | +| POST | /api/constitution/submit | 提交问卷 | 是 | +| GET | /api/constitution/result | 获取体质结果 | 是 | +| GET | /api/constitution/history | 获取测评历史 | 是 | +| GET | /api/conversations | 获取对话列表 | 是 | +| POST | /api/conversations | 创建对话 | 是 | +| GET | /api/conversations/:id | 获取对话详情 | 是 | +| DELETE | /api/conversations/:id | 删除对话 | 是 | +| POST | /api/conversations/:id/messages | 发送消息 | 是 | + +--- + +## 验收标准 + +- [ ] 获取用户资料正常 +- [ ] 获取完整健康档案正常 +- [ ] 更新资料和生活习惯正常 +- [ ] 所有 API 接口可正常调用 +- [ ] 服务器启动日志完整 + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +后端开发完成!进入 `03-Web前端开发/01-项目结构初始化.md` diff --git a/TODOS/02-后端开发/08-保健品商城关联模块.md b/TODOS/02-后端开发/08-保健品商城关联模块.md new file mode 100644 index 0000000..937dd1b --- /dev/null +++ b/TODOS/02-后端开发/08-保健品商城关联模块.md @@ -0,0 +1,673 @@ +# 08-保健品商城关联模块 + +## 目标 + +实现保健品数据管理和 AI 问诊时的产品推荐功能,关联外部保健品商城。 + +--- + +## 前置要求 + +- AI 对话模块已完成 +- 体质辨识模块已完成 + +--- + +## 实施步骤 + +### 步骤 1:创建产品数据模型 + +创建 `server/internal/model/product.go`: +```go +package model + +import "time" + +// Product 保健品 +type Product struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Category string `gorm:"size:50" json:"category"` + Description string `gorm:"type:text" json:"description"` + Efficacy string `gorm:"type:text" json:"efficacy"` + Suitable string `gorm:"type:text" json:"suitable"` + Price float64 `gorm:"type:decimal(10,2)" json:"price"` + ImageURL string `gorm:"size:255" json:"image_url"` + MallURL string `gorm:"size:255" json:"mall_url"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `json:"created_at"` +} + +// ConstitutionProduct 体质-产品关联 +type ConstitutionProduct struct { + ID uint `gorm:"primaryKey" json:"id"` + ConstitutionType string `gorm:"size:20;not null;index" json:"constitution_type"` + ProductID uint `gorm:"not null;index" json:"product_id"` + Priority int `gorm:"default:0" json:"priority"` + Reason string `gorm:"size:200" json:"reason"` +} + +// SymptomProduct 症状-产品关联 +type SymptomProduct struct { + ID uint `gorm:"primaryKey" json:"id"` + Keyword string `gorm:"size:50;not null;index" json:"keyword"` + ProductID uint `gorm:"not null;index" json:"product_id"` + Priority int `gorm:"default:0" json:"priority"` +} + +// 产品分类常量 +const ( + CategoryBuqi = "补气类" + CategoryWenyang = "温阳类" + CategoryZiyin = "滋阴类" + CategoryQushi = "祛湿类" + CategoryHuoxue = "活血类" + CategoryLiqi = "理气类" + CategoryKangmin = "抗敏类" + CategoryXinnao = "心脑血管类" + CategoryGuguanjie = "骨关节类" + CategoryXuetang = "血糖调节类" + CategoryZhumian = "助眠安神类" + CategoryJiannao = "健脑益智类" + CategoryRunchang = "润肠通便类" + CategoryHuyan = "护眼明目类" + CategoryMianyi = "增强免疫类" + CategoryZonghe = "综合类" +) +``` + +### 步骤 2:创建种子数据 + +创建 `server/internal/database/seed_products.go`: +```go +package database + +import ( + "health-ai/internal/model" + "log" +) + +// SeedProducts 初始化保健品模拟数据 +func SeedProducts() { + var count int64 + DB.Model(&model.Product{}).Count(&count) + if count > 0 { + log.Println("产品数据已存在,跳过初始化") + return + } + + products := getProductSeeds() + for _, p := range products { + DB.Create(&p) + } + log.Printf("已初始化 %d 条产品数据", len(products)) + + // 初始化体质-产品关联 + constitutionProducts := getConstitutionProductSeeds() + for _, cp := range constitutionProducts { + DB.Create(&cp) + } + log.Printf("已初始化 %d 条体质-产品关联", len(constitutionProducts)) + + // 初始化症状-产品关联 + symptomProducts := getSymptomProductSeeds() + for _, sp := range symptomProducts { + DB.Create(&sp) + } + log.Printf("已初始化 %d 条症状-产品关联", len(symptomProducts)) +} + +func getProductSeeds() []model.Product { + return []model.Product{ + // ========== 体质调养类 ========== + // 补气类 + {ID: 1, Name: "黄芪精口服液", Category: "补气类", + Efficacy: "补气固表,增强免疫力", + Suitable: "气虚质、易疲劳人群", + Price: 68.00, MallURL: "https://mall.example.com/product/1", IsActive: true}, + {ID: 2, Name: "人参蜂王浆", Category: "补气类", + Efficacy: "补气养血,改善疲劳", + Suitable: "气虚质、体力不足人群", + Price: 128.00, MallURL: "https://mall.example.com/product/2", IsActive: true}, + {ID: 3, Name: "西洋参含片", Category: "补气类", + Efficacy: "益气养阴,清热生津", + Suitable: "气虚质、气阴两虚人群", + Price: 98.00, MallURL: "https://mall.example.com/product/3", IsActive: true}, + + // 温阳类 + {ID: 4, Name: "鹿茸参精胶囊", Category: "温阳类", + Efficacy: "温肾壮阳,补气养血", + Suitable: "阳虚质、畏寒怕冷人群", + Price: 268.00, MallURL: "https://mall.example.com/product/4", IsActive: true}, + {ID: 5, Name: "桂圆红枣茶", Category: "温阳类", + Efficacy: "温中补血,养心安神", + Suitable: "阳虚质、手脚冰凉人群", + Price: 45.00, MallURL: "https://mall.example.com/product/5", IsActive: true}, + + // 滋阴类 + {ID: 6, Name: "枸杞原浆", Category: "滋阴类", + Efficacy: "滋补肝肾,明目润肺", + Suitable: "阴虚质、眼睛干涩人群", + Price: 158.00, MallURL: "https://mall.example.com/product/6", IsActive: true}, + {ID: 7, Name: "即食燕窝", Category: "滋阴类", + Efficacy: "滋阴润肺,美容养颜", + Suitable: "阴虚质、皮肤干燥人群", + Price: 398.00, MallURL: "https://mall.example.com/product/7", IsActive: true}, + {ID: 8, Name: "铁皮石斛粉", Category: "滋阴类", + Efficacy: "滋阴清热,养胃生津", + Suitable: "阴虚质、口干舌燥人群", + Price: 188.00, MallURL: "https://mall.example.com/product/8", IsActive: true}, + + // 祛湿类 + {ID: 9, Name: "红豆薏米粉", Category: "祛湿类", + Efficacy: "健脾祛湿,消肿利水", + Suitable: "痰湿质、湿热质、身体沉重人群", + Price: 39.00, MallURL: "https://mall.example.com/product/9", IsActive: true}, + {ID: 10, Name: "茯苓山药糕", Category: "祛湿类", + Efficacy: "健脾益气,祛湿止泻", + Suitable: "痰湿质、脾胃虚弱人群", + Price: 56.00, MallURL: "https://mall.example.com/product/10", IsActive: true}, + {ID: 11, Name: "清热祛湿茶", Category: "祛湿类", + Efficacy: "清热利湿,解毒消肿", + Suitable: "湿热质、口苦口干人群", + Price: 35.00, MallURL: "https://mall.example.com/product/11", IsActive: true}, + + // 活血类 + {ID: 12, Name: "三七粉", Category: "活血类", + Efficacy: "活血化瘀,消肿止痛", + Suitable: "血瘀质、面色晦暗人群", + Price: 128.00, MallURL: "https://mall.example.com/product/12", IsActive: true}, + {ID: 13, Name: "丹参片", Category: "活血类", + Efficacy: "活血化瘀,通经止痛", + Suitable: "血瘀质、易生斑点人群", + Price: 48.00, MallURL: "https://mall.example.com/product/13", IsActive: true}, + + // 理气类 + {ID: 14, Name: "玫瑰花茶", Category: "理气类", + Efficacy: "疏肝理气,美容养颜", + Suitable: "气郁质、情绪低落人群", + Price: 38.00, MallURL: "https://mall.example.com/product/14", IsActive: true}, + {ID: 15, Name: "陈皮普洱茶", Category: "理气类", + Efficacy: "理气健脾,消食化痰", + Suitable: "气郁质、胸闷不适人群", + Price: 68.00, MallURL: "https://mall.example.com/product/15", IsActive: true}, + + // 抗敏类 + {ID: 16, Name: "益生菌粉", Category: "抗敏类", + Efficacy: "调节肠道,增强免疫", + Suitable: "特禀质、过敏体质人群", + Price: 98.00, MallURL: "https://mall.example.com/product/16", IsActive: true}, + {ID: 17, Name: "蜂胶软胶囊", Category: "抗敏类", + Efficacy: "抗菌消炎,增强体质", + Suitable: "特禀质、免疫力低下人群", + Price: 168.00, MallURL: "https://mall.example.com/product/17", IsActive: true}, + + // 综合类 + {ID: 18, Name: "阿胶固元糕", Category: "综合类", + Efficacy: "补血养颜,滋阴润燥", + Suitable: "平和质、日常滋补人群", + Price: 88.00, MallURL: "https://mall.example.com/product/18", IsActive: true}, + {ID: 19, Name: "蜂蜜", Category: "综合类", + Efficacy: "润肠通便,美容养颜", + Suitable: "各种体质日常调养", + Price: 68.00, MallURL: "https://mall.example.com/product/19", IsActive: true}, + {ID: 20, Name: "复合维生素片", Category: "综合类", + Efficacy: "补充营养,增强体质", + Suitable: "各种体质日常补充", + Price: 78.00, MallURL: "https://mall.example.com/product/20", IsActive: true}, + + // ========== 中老年常见问题类 ========== + // 心脑血管类 + {ID: 21, Name: "深海鱼油软胶囊", Category: "心脑血管类", + Efficacy: "辅助降血脂,保护心脑血管", + Suitable: "高血脂、动脉硬化人群", + Price: 128.00, MallURL: "https://mall.example.com/product/21", IsActive: true}, + {ID: 22, Name: "纳豆激酶胶囊", Category: "心脑血管类", + Efficacy: "溶解血栓,改善血液循环", + Suitable: "中老年心脑血管亚健康人群", + Price: 198.00, MallURL: "https://mall.example.com/product/22", IsActive: true}, + {ID: 23, Name: "大豆卵磷脂", Category: "心脑血管类", + Efficacy: "调节血脂,保护肝脏", + Suitable: "高血脂、脂肪肝人群", + Price: 88.00, MallURL: "https://mall.example.com/product/23", IsActive: true}, + + // 骨关节类 + {ID: 24, Name: "氨糖软骨素钙片", Category: "骨关节类", + Efficacy: "修复软骨,润滑关节,补充钙质", + Suitable: "关节疼痛、骨质疏松人群", + Price: 168.00, MallURL: "https://mall.example.com/product/24", IsActive: true}, + {ID: 25, Name: "液体钙维D软胶囊", Category: "骨关节类", + Efficacy: "补钙,促进钙吸收,预防骨质疏松", + Suitable: "中老年人、骨质疏松人群", + Price: 78.00, MallURL: "https://mall.example.com/product/25", IsActive: true}, + {ID: 26, Name: "骨胶原蛋白肽", Category: "骨关节类", + Efficacy: "增加骨密度,改善关节灵活性", + Suitable: "骨关节退化人群", + Price: 218.00, MallURL: "https://mall.example.com/product/26", IsActive: true}, + + // 血糖调节类 + {ID: 27, Name: "苦瓜洋参软胶囊", Category: "血糖调节类", + Efficacy: "辅助降血糖,改善糖代谢", + Suitable: "血糖偏高人群", + Price: 138.00, MallURL: "https://mall.example.com/product/27", IsActive: true}, + {ID: 28, Name: "桑叶茶", Category: "血糖调节类", + Efficacy: "辅助稳定血糖,清热降火", + Suitable: "血糖偏高、糖尿病人群", + Price: 45.00, MallURL: "https://mall.example.com/product/28", IsActive: true}, + + // 助眠安神类 + {ID: 29, Name: "褪黑素维生素B6片", Category: "助眠安神类", + Efficacy: "改善睡眠,调节生物钟", + Suitable: "失眠、睡眠质量差人群", + Price: 68.00, MallURL: "https://mall.example.com/product/29", IsActive: true}, + {ID: 30, Name: "酸枣仁百合膏", Category: "助眠安神类", + Efficacy: "养心安神,改善睡眠", + Suitable: "心烦失眠、多梦易醒人群", + Price: 58.00, MallURL: "https://mall.example.com/product/30", IsActive: true}, + + // 健脑益智类 + {ID: 31, Name: "银杏叶提取物片", Category: "健脑益智类", + Efficacy: "改善记忆力,促进脑部血液循环", + Suitable: "记忆力减退、脑供血不足人群", + Price: 98.00, MallURL: "https://mall.example.com/product/31", IsActive: true}, + {ID: 32, Name: "DHA藻油软胶囊", Category: "健脑益智类", + Efficacy: "补充脑营养,改善认知功能", + Suitable: "中老年脑功能下降人群", + Price: 158.00, MallURL: "https://mall.example.com/product/32", IsActive: true}, + + // 润肠通便类 + {ID: 33, Name: "膳食纤维粉", Category: "润肠通便类", + Efficacy: "促进肠道蠕动,改善便秘", + Suitable: "便秘、肠道功能紊乱人群", + Price: 48.00, MallURL: "https://mall.example.com/product/33", IsActive: true}, + {ID: 34, Name: "综合酵素原液", Category: "润肠通便类", + Efficacy: "促进消化,排毒通便", + Suitable: "消化不良、便秘人群", + Price: 128.00, MallURL: "https://mall.example.com/product/34", IsActive: true}, + + // 护眼明目类 + {ID: 35, Name: "叶黄素蓝莓护眼片", Category: "护眼明目类", + Efficacy: "保护视网膜,缓解眼疲劳", + Suitable: "视力下降、眼睛干涩人群", + Price: 118.00, MallURL: "https://mall.example.com/product/35", IsActive: true}, + + // 增强免疫类 + {ID: 36, Name: "灵芝孢子粉胶囊", Category: "增强免疫类", + Efficacy: "增强免疫力,抗疲劳", + Suitable: "免疫力低下、体质虚弱人群", + Price: 298.00, MallURL: "https://mall.example.com/product/36", IsActive: true}, + } +} + +func getConstitutionProductSeeds() []model.ConstitutionProduct { + return []model.ConstitutionProduct{ + // 气虚质推荐 + {ConstitutionType: "qixu", ProductID: 1, Priority: 1, Reason: "补气固表"}, + {ConstitutionType: "qixu", ProductID: 2, Priority: 2, Reason: "补气养血"}, + {ConstitutionType: "qixu", ProductID: 3, Priority: 3, Reason: "益气养阴"}, + {ConstitutionType: "qixu", ProductID: 36, Priority: 4, Reason: "增强免疫"}, + + // 阳虚质推荐 + {ConstitutionType: "yangxu", ProductID: 4, Priority: 1, Reason: "温肾壮阳"}, + {ConstitutionType: "yangxu", ProductID: 5, Priority: 2, Reason: "温中补血"}, + + // 阴虚质推荐 + {ConstitutionType: "yinxu", ProductID: 6, Priority: 1, Reason: "滋补肝肾"}, + {ConstitutionType: "yinxu", ProductID: 7, Priority: 2, Reason: "滋阴润肺"}, + {ConstitutionType: "yinxu", ProductID: 8, Priority: 3, Reason: "滋阴清热"}, + + // 痰湿质推荐 + {ConstitutionType: "tanshi", ProductID: 9, Priority: 1, Reason: "健脾祛湿"}, + {ConstitutionType: "tanshi", ProductID: 10, Priority: 2, Reason: "健脾益气"}, + + // 湿热质推荐 + {ConstitutionType: "shire", ProductID: 11, Priority: 1, Reason: "清热利湿"}, + {ConstitutionType: "shire", ProductID: 9, Priority: 2, Reason: "祛湿利水"}, + + // 血瘀质推荐 + {ConstitutionType: "xueyu", ProductID: 12, Priority: 1, Reason: "活血化瘀"}, + {ConstitutionType: "xueyu", ProductID: 13, Priority: 2, Reason: "活血通经"}, + {ConstitutionType: "xueyu", ProductID: 21, Priority: 3, Reason: "改善血液循环"}, + + // 气郁质推荐 + {ConstitutionType: "qiyu", ProductID: 14, Priority: 1, Reason: "疏肝理气"}, + {ConstitutionType: "qiyu", ProductID: 15, Priority: 2, Reason: "理气健脾"}, + {ConstitutionType: "qiyu", ProductID: 30, Priority: 3, Reason: "安神助眠"}, + + // 特禀质推荐 + {ConstitutionType: "tebing", ProductID: 16, Priority: 1, Reason: "调节免疫"}, + {ConstitutionType: "tebing", ProductID: 17, Priority: 2, Reason: "增强体质"}, + + // 平和质推荐 + {ConstitutionType: "pinghe", ProductID: 18, Priority: 1, Reason: "日常滋补"}, + {ConstitutionType: "pinghe", ProductID: 20, Priority: 2, Reason: "营养补充"}, + } +} + +func getSymptomProductSeeds() []model.SymptomProduct { + return []model.SymptomProduct{ + // 疲劳相关 + {Keyword: "疲劳", ProductID: 1, Priority: 1}, + {Keyword: "乏力", ProductID: 1, Priority: 1}, + {Keyword: "没精神", ProductID: 2, Priority: 1}, + {Keyword: "体力差", ProductID: 2, Priority: 1}, + + // 睡眠相关 + {Keyword: "失眠", ProductID: 29, Priority: 1}, + {Keyword: "睡不着", ProductID: 29, Priority: 1}, + {Keyword: "多梦", ProductID: 30, Priority: 1}, + {Keyword: "睡眠差", ProductID: 30, Priority: 1}, + + // 心脑血管相关 + {Keyword: "血压高", ProductID: 21, Priority: 1}, + {Keyword: "高血压", ProductID: 21, Priority: 1}, + {Keyword: "血脂高", ProductID: 21, Priority: 1}, + {Keyword: "高血脂", ProductID: 23, Priority: 1}, + {Keyword: "头晕", ProductID: 22, Priority: 1}, + {Keyword: "动脉硬化", ProductID: 22, Priority: 1}, + + // 骨关节相关 + {Keyword: "关节痛", ProductID: 24, Priority: 1}, + {Keyword: "膝盖疼", ProductID: 24, Priority: 1}, + {Keyword: "腿疼", ProductID: 24, Priority: 1}, + {Keyword: "骨质疏松", ProductID: 25, Priority: 1}, + {Keyword: "缺钙", ProductID: 25, Priority: 1}, + + // 血糖相关 + {Keyword: "血糖高", ProductID: 27, Priority: 1}, + {Keyword: "糖尿病", ProductID: 28, Priority: 1}, + + // 记忆力相关 + {Keyword: "记忆力差", ProductID: 31, Priority: 1}, + {Keyword: "健忘", ProductID: 31, Priority: 1}, + {Keyword: "脑供血不足", ProductID: 32, Priority: 1}, + + // 消化相关 + {Keyword: "便秘", ProductID: 33, Priority: 1}, + {Keyword: "排便困难", ProductID: 33, Priority: 1}, + {Keyword: "消化不良", ProductID: 34, Priority: 1}, + + // 眼睛相关 + {Keyword: "眼睛干", ProductID: 35, Priority: 1}, + {Keyword: "视力差", ProductID: 35, Priority: 1}, + {Keyword: "眼疲劳", ProductID: 35, Priority: 1}, + + // 免疫相关 + {Keyword: "感冒", ProductID: 36, Priority: 1}, + {Keyword: "免疫力差", ProductID: 36, Priority: 1}, + {Keyword: "体质差", ProductID: 36, Priority: 1}, + + // 体质相关症状 + {Keyword: "怕冷", ProductID: 4, Priority: 1}, + {Keyword: "手脚冰凉", ProductID: 5, Priority: 1}, + {Keyword: "口干", ProductID: 6, Priority: 1}, + {Keyword: "上火", ProductID: 11, Priority: 1}, + {Keyword: "湿气重", ProductID: 9, Priority: 1}, + } +} +``` + +### 步骤 3:创建产品 Repository + +创建 `server/internal/repository/impl/product.go`: +```go +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type ProductRepository struct{} + +func NewProductRepository() *ProductRepository { + return &ProductRepository{} +} + +// GetAll 获取所有产品 +func (r *ProductRepository) GetAll(category string) ([]model.Product, error) { + var products []model.Product + query := database.DB.Where("is_active = ?", true) + if category != "" { + query = query.Where("category = ?", category) + } + err := query.Find(&products).Error + return products, err +} + +// GetByID 获取产品详情 +func (r *ProductRepository) GetByID(id uint) (*model.Product, error) { + var product model.Product + err := database.DB.First(&product, id).Error + return &product, err +} + +// GetByConstitution 根据体质获取推荐产品 +func (r *ProductRepository) GetByConstitution(constitutionType string, limit int) ([]model.Product, error) { + var products []model.Product + err := database.DB. + Joins("JOIN constitution_products ON constitution_products.product_id = products.id"). + Where("constitution_products.constitution_type = ?", constitutionType). + Where("products.is_active = ?", true). + Order("constitution_products.priority ASC"). + Limit(limit). + Find(&products).Error + return products, err +} + +// GetByKeywords 根据症状关键词获取推荐产品 +func (r *ProductRepository) GetByKeywords(keywords []string, limit int) ([]model.Product, error) { + var products []model.Product + err := database.DB. + Joins("JOIN symptom_products ON symptom_products.product_id = products.id"). + Where("symptom_products.keyword IN ?", keywords). + Where("products.is_active = ?", true). + Order("symptom_products.priority ASC"). + Distinct(). + Limit(limit). + Find(&products).Error + return products, err +} + +// GetRecommendations 综合推荐(体质+关键词) +func (r *ProductRepository) GetRecommendations(constitutionType string, keywords []string, limit int) ([]model.Product, error) { + // 优先获取体质相关产品 + products, _ := r.GetByConstitution(constitutionType, limit) + + // 如果不够,补充关键词匹配的产品 + if len(products) < limit && len(keywords) > 0 { + remaining := limit - len(products) + keywordProducts, _ := r.GetByKeywords(keywords, remaining) + + // 去重合并 + existingIDs := make(map[uint]bool) + for _, p := range products { + existingIDs[p.ID] = true + } + for _, p := range keywordProducts { + if !existingIDs[p.ID] { + products = append(products, p) + } + } + } + + return products, nil +} +``` + +### 步骤 4:更新对话 Service + +在 `conversation.go` 中添加产品推荐: + +```go +// 更新系统提示词模板,添加产品列表 +func (s *ConversationService) buildSystemPrompt(userID uint) string { + // ... 原有代码 ... + + // 获取用户体质相关产品(用于 AI 推荐) + var productList string + if constitution != nil { + products, _ := s.productRepo.GetByConstitution(constitution.PrimaryConstitution, 5) + if len(products) > 0 { + productList = "可推荐产品:\n" + for _, p := range products { + productList += fmt.Sprintf("- %s ¥%.0f %s\n", p.Name, p.Price, p.MallURL) + } + } + } + + return fmt.Sprintf(systemPromptTemplate, userProfile, constitutionInfo, medicationHistory, productList) +} +``` + +### 步骤 5:创建产品 Handler + +创建 `server/internal/api/handler/product.go`: +```go +package handler + +import ( + "health-ai/internal/api/middleware" + "health-ai/internal/repository/impl" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type ProductHandler struct { + productRepo *impl.ProductRepository + constitutionRepo *impl.ConstitutionRepository +} + +func NewProductHandler() *ProductHandler { + return &ProductHandler{ + productRepo: impl.NewProductRepository(), + constitutionRepo: impl.NewConstitutionRepository(), + } +} + +// GetProducts 获取产品列表 +func (h *ProductHandler) GetProducts(c *gin.Context) { + category := c.Query("category") + products, err := h.productRepo.GetAll(category) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, products) +} + +// GetProduct 获取产品详情 +func (h *ProductHandler) GetProduct(c *gin.Context) { + var id uint + fmt.Sscanf(c.Param("id"), "%d", &id) + + product, err := h.productRepo.GetByID(id) + if err != nil { + response.Error(c, 404, "产品不存在") + return + } + response.Success(c, product) +} + +// GetRecommendProducts 获取推荐产品 +func (h *ProductHandler) GetRecommendProducts(c *gin.Context) { + userID := middleware.GetUserID(c) + + // 获取用户体质 + constitution, err := h.constitutionRepo.GetLatestAssessment(userID) + if err != nil { + response.Error(c, 400, "请先完成体质测评") + return + } + + products, err := h.productRepo.GetByConstitution(constitution.PrimaryConstitution, 6) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, products) +} + +// SearchProducts 搜索产品 +func (h *ProductHandler) SearchProducts(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + response.Error(c, 400, "请输入搜索关键词") + return + } + + products, err := h.productRepo.GetByKeywords([]string{keyword}, 10) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, products) +} +``` + +### 步骤 6:更新路由 + +在 `router.go` 中添加: +```go +// 产品路由 +products := api.Group("/products") +{ + products.GET("", productHandler.GetProducts) + products.GET("/:id", productHandler.GetProduct) + products.GET("/recommend", authMiddleware, productHandler.GetRecommendProducts) + products.GET("/search", productHandler.SearchProducts) +} +``` + +### 步骤 7:更新数据库初始化 + +在 `main.go` 中调用: +```go +// 初始化产品数据 +database.SeedProducts() +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `internal/model/product.go` | 产品数据模型 | +| `internal/database/seed_products.go` | 产品种子数据(36条) | +| `internal/repository/impl/product.go` | 产品 Repository | +| `internal/api/handler/product.go` | 产品 Handler | + +--- + +## 模拟数据统计 + +| 分类 | 数量 | 说明 | +|------|------|------| +| 体质调养类 | 20 | 补气、温阳、滋阴、祛湿、活血、理气、抗敏、综合 | +| 中老年常见类 | 16 | 心脑血管、骨关节、血糖、助眠、健脑、润肠、护眼、免疫 | +| **总计** | **36** | - | + +--- + +## 验收标准 + +- [ ] 产品列表正常显示 +- [ ] 按分类筛选正常 +- [ ] 根据体质推荐产品正常 +- [ ] 根据症状搜索产品正常 +- [ ] AI 回答中包含产品推荐链接 +- [ ] 种子数据正确初始化 + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +后端开发全部完成!进入 `03-Web前端开发/01-项目结构初始化.md` diff --git a/TODOS/03-Web前端开发/01-项目结构初始化.md b/TODOS/03-Web前端开发/01-项目结构初始化.md new file mode 100644 index 0000000..2de87cf --- /dev/null +++ b/TODOS/03-Web前端开发/01-项目结构初始化.md @@ -0,0 +1,429 @@ +# 01-Web 前端项目结构初始化 + +## 目标 + +使用 Vite 创建 Vue 3 + TypeScript 项目,配置基础依赖和目录结构。 + +--- + +## 前置要求 + +- Node.js 18+ 已安装 +- npm/pnpm 已配置 + +--- + +## 实施步骤 + +### 步骤 1:创建 Vue 3 项目 + +```bash +cd I:\apps\demo\healthApps + +# 使用 Vite 创建项目 +npm create vite@latest web -- --template vue-ts + +cd web +``` + +### 步骤 2:安装依赖 + +```bash +# 基础依赖 +npm install + +# 路由 +npm install vue-router@4 + +# 状态管理 +npm install pinia + +# UI 组件库 +npm install element-plus +npm install @element-plus/icons-vue + +# HTTP 请求 +npm install axios + +# 图表(体质雷达图) +npm install echarts vue-echarts + +# 工具库 +npm install dayjs +npm install lodash-es +npm install @types/lodash-es -D +``` + +### 步骤 3:创建目录结构 + +```bash +cd src + +# 创建目录 +mkdir -p api +mkdir -p components/common +mkdir -p components/survey +mkdir -p components/constitution +mkdir -p components/chat +mkdir -p views/auth +mkdir -p views/survey +mkdir -p views/constitution +mkdir -p views/chat +mkdir -p views/profile +mkdir -p stores +mkdir -p router +mkdir -p utils +mkdir -p types +mkdir -p assets/styles +``` + +### 步骤 4:配置 Element Plus + +更新 `src/main.ts`: +```typescript +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' + +import './assets/styles/global.css' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') +``` + +### 步骤 5:创建全局样式 + +创建 `src/assets/styles/global.css`: +```css +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-thumb { + background-color: #ddd; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #ccc; +} + +/* 通用类 */ +.page-container { + min-height: 100%; + padding: 20px; + background-color: #f5f7fa; +} + +.card { + background: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +``` + +### 步骤 6:创建类型定义 + +创建 `src/types/index.ts`: +```typescript +// 用户相关 +export interface User { + id: number + phone: string + email: string + nickname: string + avatar: string + survey_completed: boolean +} + +// 健康档案 +export interface HealthProfile { + id: number + name: string + birth_date: string + gender: string + height: number + weight: number + bmi: number + blood_type: string + occupation: string + marital_status: string + region: string +} + +// 生活习惯 +export interface LifestyleInfo { + sleep_time: string + wake_time: string + sleep_quality: string + meal_regularity: string + diet_preference: string + daily_water_ml: number + exercise_frequency: string + exercise_type: string + exercise_duration_min: number + is_smoker: boolean + alcohol_frequency: string +} + +// 体质问题 +export interface Question { + id: number + constitution_type: string + question_text: string + options: string[] + order_num: number +} + +// 体质得分 +export interface ConstitutionScore { + type: string + name: string + score: number + description: string +} + +// 体质结果 +export interface ConstitutionResult { + primary_constitution: ConstitutionScore + secondary_constitutions: ConstitutionScore[] + all_scores: ConstitutionScore[] + recommendations: Record> + 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 { + 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` diff --git a/TODOS/03-Web前端开发/02-路由和布局设计.md b/TODOS/03-Web前端开发/02-路由和布局设计.md new file mode 100644 index 0000000..3401092 --- /dev/null +++ b/TODOS/03-Web前端开发/02-路由和布局设计.md @@ -0,0 +1,594 @@ +# 02-路由和布局设计 + +## 目标 + +配置 Vue Router 路由系统,实现页面布局和导航守卫。 + +--- + +## 前置要求 + +- 项目结构已初始化 +- Vue Router 已安装 + +--- + +## 实施步骤 + +### 步骤 1:创建用户 Store + +创建 `src/stores/user.ts`: +```typescript +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useUserStore = defineStore('user', () => { + const token = ref(localStorage.getItem('token') || '') + const userInfo = ref(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 + + + +``` + +### 步骤 4:创建主布局组件 + +创建 `src/components/common/MainLayout.vue`: +```vue + + + + + +``` + +### 步骤 5:创建 Logo 占位 + +创建 `src/assets/logo.svg`: +```svg + + + + + +``` + +### 步骤 6:更新 App.vue + +更新 `src/App.vue`: +```vue + + + +``` + +### 步骤 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` diff --git a/TODOS/03-Web前端开发/03-用户认证页面.md b/TODOS/03-Web前端开发/03-用户认证页面.md new file mode 100644 index 0000000..e8926d4 --- /dev/null +++ b/TODOS/03-Web前端开发/03-用户认证页面.md @@ -0,0 +1,478 @@ +# 03-用户认证页面 + +## 目标 + +实现登录和注册页面,完成用户认证功能。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/登录页.png` + +### 页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色渐变背景 (`#10B981 → #2EC4B6`) + 医疗插图 | +| Logo | "AI健康助手" 标题 + "您的专属健康管理伙伴" | +| 表单 | 白色圆角卡片 (16px),内边距 24px | +| 输入框 | 灰色背景 `#F3F4F6`,圆角 24px | +| 主按钮 | 绿色 `#10B981`,全宽,圆角 24px | +| 底部 | 注册/登录切换链接 | + +### 配色规范 + +```scss +$primary: #10B981; // 主色调 +$primary-dark: #059669; // 深色(hover) +$bg-page: #F5F5F5; // 页面背景 +$bg-input: #F3F4F6; // 输入框背景 +$text-main: #1F2937; // 主文字 +$text-sub: #6B7280; // 次文字 +$text-hint: #9CA3AF; // 提示文字 +``` + +--- + +## 前置要求 + +- 路由和布局已配置 +- 后端认证接口可用 + +--- + +## 实施步骤 + +### 步骤 1:创建认证 API + +创建 `src/api/auth.ts`: +```typescript +import request from './request' + +export interface LoginRequest { + phone: string + password: string +} + +export interface RegisterRequest { + phone: string + password: string + nickname?: string +} + +export interface AuthResponse { + token: string + user_id: number + nickname: string + survey_completed: boolean +} + +export const login = (data: LoginRequest): Promise => { + return request.post('/auth/login', data) +} + +export const register = (data: RegisterRequest): Promise => { + return request.post('/auth/register', data) +} +``` + +### 步骤 2:创建登录页面 + +创建 `src/views/auth/Login.vue`: +```vue + + + + + +``` + +### 步骤 3:创建注册页面 + +创建 `src/views/auth/Register.vue`: +```vue + + + + + +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `src/api/auth.ts` | 认证 API | +| `src/views/auth/Login.vue` | 登录页面 | +| `src/views/auth/Register.vue` | 注册页面 | + +--- + +## 功能说明 + +### 登录页面 +- 手机号+密码登录 +- 表单验证 +- 登录成功后: + - 已完成调查 → 跳转首页 + - 未完成调查 → 跳转健康调查页 + +### 注册页面 +- 手机号+密码注册 +- 可选昵称 +- 密码确认 +- 用户协议确认 +- 注册成功后跳转健康调查页 + +--- + +## 验收标准 + +- [ ] 登录表单验证正常 +- [ ] 注册表单验证正常 +- [ ] 登录成功保存 Token +- [ ] 注册成功自动登录 +- [ ] 路由跳转逻辑正确 + +--- + +## 预计耗时 + +20-25 分钟 + +--- + +## 下一步 + +完成后进入 `03-Web前端开发/04-健康调查页面.md` diff --git a/TODOS/03-Web前端开发/04-健康调查页面.md b/TODOS/03-Web前端开发/04-健康调查页面.md new file mode 100644 index 0000000..56ff86f --- /dev/null +++ b/TODOS/03-Web前端开发/04-健康调查页面.md @@ -0,0 +1,845 @@ +# 04-健康调查页面 + +## 目标 + +实现新用户健康调查功能,包括基础信息、生活习惯、病史等多步骤表单。 + +--- + +## 前置要求 + +- 用户认证功能完成 +- 后端调查接口可用 + +--- + +## 实施步骤 + +### 步骤 1:创建调查 API + +创建 `src/api/survey.ts`: +```typescript +import request from './request' + +export const getSurveyStatus = () => { + return request.get('/survey/status') +} + +export const submitBasicInfo = (data: any) => { + return request.post('/survey/basic-info', data) +} + +export const submitLifestyle = (data: any) => { + return request.post('/survey/lifestyle', data) +} + +export const submitMedicalHistory = (data: any) => { + return request.post('/survey/medical-history', data) +} + +export const submitFamilyHistory = (data: any) => { + return request.post('/survey/family-history', data) +} + +export const submitAllergy = (data: any) => { + return request.post('/survey/allergy', data) +} +``` + +### 步骤 2:创建调查主页面 + +创建 `src/views/survey/Index.vue`: +```vue + + + + + +``` + +### 步骤 3:创建基础信息表单组件 + +创建 `src/components/survey/BasicInfoForm.vue`: +```vue + + + + + +``` + +### 步骤 4:创建生活习惯表单组件 + +创建 `src/components/survey/LifestyleForm.vue`: +```vue + + + + + +``` + +### 步骤 5:创建健康状况表单组件 + +创建 `src/components/survey/HealthStatusForm.vue`: +```vue + + + + + +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `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` diff --git a/TODOS/03-Web前端开发/05-体质辨识页面.md b/TODOS/03-Web前端开发/05-体质辨识页面.md new file mode 100644 index 0000000..51971f1 --- /dev/null +++ b/TODOS/03-Web前端开发/05-体质辨识页面.md @@ -0,0 +1,691 @@ +# 05-体质辨识页面 + +## 目标 + +实现中医体质辨识问卷页面,包括问卷填写和结果展示。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/体质页.png`、`files/ui/体质检测.png`、`files/ui/体质分析.png` + +### 体质首页布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部卡片 | 绿色渐变背景,"中医体质自测" 标题 + 介绍文案 | +| 测试说明 | 白色卡片,3步骤(绿色序号圆圈 + 说明文字) | +| 按钮 | 绿色全宽圆角按钮 "开始测试",圆角 24px | + +### 问卷页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 导航栏 | 返回箭头 + "中医体质辨识自测" + 进度 "1/65" | +| 进度条 | 绿色细条 `#10B981`,高度 4px | +| 问题标签 | 绿色背景 `#DCFCE7`,文字 `#10B981`,"问题N" | +| 问题文字 | 字号 18px,颜色 `#1F2937` | +| 选项按钮 | 白色背景,边框 `#E5E7EB`,圆角 12px,选中高亮绿色 | +| 底部提示 | 浅绿色背景 `#ECFDF5`,带 info 图标 | + +### 结果页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色渐变背景 + 人体轮廓图 + 分享按钮 | +| 体质名称 | 大字体 32px,白色 | +| 分数徽章 | 绿色背景圆角标签 "85分" | +| 雷达图 | 白色卡片,九种体质得分可视化,颜色 `#10B981` | +| 体质特征 | 图标 + 描述文字 | +| 调理建议 | 2×2 网格布局 | + +### 调理建议卡片配色 + +| 类型 | 图标背景 | 图标颜色 | +|------|----------|----------| +| 起居 | `#EDE9FE` | `#8B5CF6` | +| 饮食 | `#CCFBF1` | `#14B8A6` | +| 运动 | `#EDE9FE` | `#8B5CF6` | +| 情志 | `#FCE7F3` | `#EC4899` | + +--- + +## 前置要求 + +- 健康调查页面完成 +- 后端体质接口可用 + +--- + +## 实施步骤 + +### 步骤 1:创建体质 API + +创建 `src/api/constitution.ts`: +```typescript +import request from './request' +import type { Question, ConstitutionResult } from '@/types' + +export const getQuestions = (): Promise => { + return request.get('/constitution/questions') +} + +export const submitAssessment = (answers: { question_id: number; score: number }[]): Promise => { + return request.post('/constitution/submit', { answers }) +} + +export const getLatestResult = (): Promise => { + return request.get('/constitution/result') +} + +export const getAssessmentHistory = () => { + return request.get('/constitution/history') +} +``` + +### 步骤 2:创建体质测评主页面 + +创建 `src/views/constitution/Index.vue`: +```vue + + + + + +``` + +### 步骤 3:创建结果展示页面 + +创建 `src/views/constitution/Result.vue`: +```vue + + + + + +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `src/api/constitution.ts` | 体质 API | +| `src/views/constitution/Index.vue` | 测评主页面 | +| `src/views/constitution/Result.vue` | 结果展示页面 | + +--- + +## 验收标准 + +- [ ] 问卷题目正确加载 +- [ ] 答题进度显示正常 +- [ ] 提交后跳转结果页 +- [ ] 雷达图正确显示 +- [ ] 调养建议展示完整 + +--- + +## 预计耗时 + +35-40 分钟 + +--- + +## 下一步 + +完成后进入 `03-Web前端开发/06-AI对话页面.md` diff --git a/TODOS/03-Web前端开发/06-AI对话页面.md b/TODOS/03-Web前端开发/06-AI对话页面.md new file mode 100644 index 0000000..ff68e43 --- /dev/null +++ b/TODOS/03-Web前端开发/06-AI对话页面.md @@ -0,0 +1,616 @@ +# 06-AI对话页面 + +## 目标 + +实现 AI 健康问诊对话功能,支持多轮对话和对话历史管理。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/问答页.png`、`files/ui/问答对话.png` + +### 对话首页布局 + +| 区域 | 设计要点 | +|------|----------| +| 导航栏 | "AI健康助手" + 绿色 "在线" 状态 + 更多菜单 | +| AI 欢迎语 | 机器人图标(蓝色背景 `#3B82F6`)+ 灰色气泡 | +| 常见问题 | "常见问题" 标签(灰色)+ 快捷问题按钮(白色圆角) | +| 输入区 | 麦克风图标 + 输入框(灰色背景)+ 绿色发送按钮 | + +### 消息气泡样式 + +| 类型 | 样式 | +|------|------| +| AI 消息 | 左对齐,机器人图标(蓝色 `#3B82F6`),灰色气泡 `#F3F4F6` | +| 用户消息 | 右对齐,用户图标(绿色 `#10B981`),绿色气泡 `#10B981`,白色文字 | +| 时间显示 | 灰色小字 `#9CA3AF`,位于消息下方 | + +### 输入区样式 + +| 元素 | 样式 | +|------|------| +| 麦克风 | 灰色图标 `#9CA3AF` | +| 输入框 | 灰色背景 `#F3F4F6`,圆角 24px,占位符 "请输入您的健康问题..." | +| 发送按钮 | 绿色圆形按钮 `#10B981`,飞机图标 | + +### 快捷问题示例 + +- 我最近总是感觉疲劳怎么办? +- 如何改善睡眠质量? +- 有什么养生建议吗? +- 感冒了应该注意什么? + +--- + +## 前置要求 + +- 体质测评页面完成 +- 后端对话接口可用 + +--- + +## 实施步骤 + +### 步骤 1:创建对话 API + +创建 `src/api/conversation.ts`: +```typescript +import request from './request' +import type { Conversation, Message } from '@/types' + +export const getConversations = (): Promise => { + return request.get('/conversations') +} + +export const createConversation = (title?: string): Promise => { + return request.post('/conversations', { title }) +} + +export const getConversation = (id: number): Promise => { + 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 + + + + + +``` + +### 步骤 3:创建对话详情页面 + +创建 `src/views/chat/Detail.vue`: +```vue + + + + + +``` + +### 步骤 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` diff --git a/TODOS/03-Web前端开发/07-个人中心页面.md b/TODOS/03-Web前端开发/07-个人中心页面.md new file mode 100644 index 0000000..dfd2ece --- /dev/null +++ b/TODOS/03-Web前端开发/07-个人中心页面.md @@ -0,0 +1,588 @@ +# 07-个人中心页面 + +## 目标 + +实现个人中心和健康档案管理页面。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/我的.png` + +### 页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色背景 `#10B981` + "我的" 标题(白色) | +| 用户卡片 | 头像(圆形)+ 姓名 + 基本信息 + 用户ID + 编辑按钮 | +| 健康管理 | "用药情况" 入口(带数量角标 `12条`) | +| 设置列表 | 消息通知、隐私设置、通用设置 | + +### 用户卡片样式 + +| 元素 | 样式 | +|------|------| +| 头像 | 64px 圆形,浅绿色背景 | +| 姓名 | 白色,字号 20px | +| 基本信息 | 白色,格式 "男·28岁·175cm·70kg" | +| 用户ID | 浅白色 `rgba(255,255,255,0.7)` | +| 编辑按钮 | 白色半透明背景,编辑图标 | + +### 列表项样式 + +| 元素 | 样式 | +|------|------| +| 图标背景 | 40px 圆形,各功能不同颜色 | +| 用药情况 | 绿色背景 `#DCFCE7`,图标 `#10B981` | +| 消息通知 | 绿色背景 `#DCFCE7`,铃铛图标 | +| 隐私设置 | 绿色背景 `#DCFCE7`,盾牌图标 | +| 通用设置 | 绿色背景 `#DCFCE7`,齿轮图标 | +| 右箭头 | 灰色 `#9CA3AF` | +| 角标 | 灰色文字 "12条" | + +--- + +## 前置要求 + +- 前面所有页面完成 +- 后端用户接口可用 + +--- + +## 实施步骤 + +### 步骤 1:创建个人中心页面 + +创建 `src/views/profile/Index.vue`: +```vue + + + + + +``` + +### 步骤 2:创建健康档案页面 + +创建 `src/views/profile/HealthRecord.vue`: +```vue + + + + + +``` + +--- + +## 需要创建的文件清单 + +| 文件路径 | 说明 | +|----------|------| +| `src/views/profile/Index.vue` | 个人中心页面 | +| `src/views/profile/HealthRecord.vue` | 健康档案页面 | + +--- + +## 验收标准 + +- [ ] 个人中心显示用户信息 +- [ ] 编辑资料功能正常 +- [ ] 健康档案数据正确显示 +- [ ] 各菜单跳转正常 +- [ ] 退出登录功能正常 + +--- + +## 预计耗时 + +25-30 分钟 + +--- + +## 下一步 + +Web 前端开发完成!进入 `04-APP开发/01-项目结构初始化.md` diff --git a/TODOS/03-Web原型开发/01-项目初始化和模拟数据.md b/TODOS/03-Web原型开发/01-项目初始化和模拟数据.md new file mode 100644 index 0000000..c378f5c --- /dev/null +++ b/TODOS/03-Web原型开发/01-项目初始化和模拟数据.md @@ -0,0 +1,277 @@ +# 01-项目初始化和模拟数据 + +## 目标 + +初始化 Vue 3 项目并搭建模拟数据服务,为原型开发提供完整的数据支持。 + +--- + +## 前置要求 + +- 已完成 Web 前端 Vue 环境搭建 +- Node.js 18+ + +--- + +## 实施步骤 + +### 步骤 1:创建 Vue 3 项目 + +```bash +# 进入项目目录 +cd I:/apps/demo/healthApps + +# 创建 Vue 3 项目 +npm create vite@latest web -- --template vue-ts + +# 进入项目 +cd web + +# 安装依赖 +npm install +``` + +### 步骤 2:安装核心依赖 + +```bash +# 路由 +npm install vue-router@4 + +# 状态管理 +npm install pinia + +# UI 组件库 +npm install element-plus @element-plus/icons-vue + +# HTTP 请求 +npm install axios + +# 图表 +npm install echarts vue-echarts + +# 工具 +npm install dayjs lodash-es +npm install -D @types/lodash-es +``` + +### 步骤 3:创建项目目录结构 + +``` +web/ +├── src/ +│ ├── api/ # API 接口(后续对接用) +│ ├── assets/ # 静态资源 +│ ├── components/ # 公共组件 +│ │ ├── AppHeader.vue +│ │ ├── AppFooter.vue +│ │ └── Loading.vue +│ ├── mock/ # 模拟数据 ⭐ +│ │ ├── index.ts # 统一导出 +│ │ ├── user.ts # 用户数据 +│ │ ├── constitution.ts # 体质问卷和结果 +│ │ ├── chat.ts # AI 对话数据 +│ │ └── products.ts # 产品数据 +│ ├── router/ # 路由配置 +│ │ └── index.ts +│ ├── stores/ # Pinia 状态 +│ │ ├── auth.ts +│ │ ├── constitution.ts +│ │ └── chat.ts +│ ├── styles/ # 全局样式 +│ │ └── index.scss +│ ├── types/ # TypeScript 类型 +│ │ └── index.ts +│ ├── utils/ # 工具函数 +│ │ └── index.ts +│ ├── views/ # 页面 +│ │ ├── auth/ # 登录相关 +│ │ ├── home/ # 首页 +│ │ ├── constitution/ # 体质辨识 +│ │ ├── chat/ # AI 对话 +│ │ └── profile/ # 个人中心 +│ ├── App.vue +│ └── main.ts +├── index.html +├── vite.config.ts +└── package.json +``` + +### 步骤 4:创建类型定义 + +创建 `src/types/index.ts`(与 APP 端共用类型定义): + +```typescript +// 用户类型 +export interface User { + id: number + phone: string + nickname: string + avatar: string + surveyCompleted: boolean +} + +// 体质类型 +export type ConstitutionType = + | 'pinghe' | 'qixu' | 'yangxu' | 'yinxu' | 'tanshi' + | 'shire' | 'xueyu' | 'qiyu' | 'tebing' + +// 体质问卷题目 +export interface ConstitutionQuestion { + id: number + constitutionType: ConstitutionType + question: string + options: { value: number; label: string }[] +} + +// 体质评估结果 +export interface ConstitutionResult { + primaryType: ConstitutionType + scores: Record + 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` diff --git a/TODOS/03-Web原型开发/02-路由和布局设计.md b/TODOS/03-Web原型开发/02-路由和布局设计.md new file mode 100644 index 0000000..e53fdff --- /dev/null +++ b/TODOS/03-Web原型开发/02-路由和布局设计.md @@ -0,0 +1,363 @@ +# 02-路由和布局设计 + +## 目标 + +配置 Vue Router 路由系统,实现页面布局和导航。 + +--- + +## 前置要求 + +- 项目结构已初始化 +- Vue Router 已安装 +- 模拟数据服务已创建 + +--- + +## 实施步骤 + +### 步骤 1:创建路由配置 + +创建 `src/router/index.ts`: + +```typescript +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/auth/LoginView.vue'), + meta: { requiresAuth: false } + }, + { + path: '/', + component: () => import('@/views/layout/MainLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Home', + component: () => import('@/views/home/HomeView.vue') + }, + { + path: 'chat', + name: 'ChatList', + component: () => import('@/views/chat/ChatListView.vue') + }, + { + path: 'chat/:id', + name: 'ChatDetail', + component: () => import('@/views/chat/ChatDetailView.vue') + }, + { + path: 'constitution', + name: 'Constitution', + component: () => import('@/views/constitution/ConstitutionView.vue') + }, + { + path: 'constitution/test', + name: 'ConstitutionTest', + component: () => import('@/views/constitution/ConstitutionTestView.vue') + }, + { + path: 'constitution/result', + name: 'ConstitutionResult', + component: () => import('@/views/constitution/ConstitutionResultView.vue') + }, + { + path: 'profile', + name: 'Profile', + component: () => import('@/views/profile/ProfileView.vue') + }, + { + path: 'profile/health-record', + name: 'HealthRecord', + component: () => import('@/views/profile/HealthRecordView.vue') + } + ] + } + ] +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isLoggedIn) { + next('/login') + } else if (to.path === '/login' && authStore.isLoggedIn) { + next('/') + } else { + next() + } +}) + +export default router +``` + +### 步骤 2:创建认证状态 Store + +创建 `src/stores/auth.ts`: + +```typescript +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { User } from '@/types' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(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 + + + + + +``` + +### 步骤 4:更新 App.vue + +```vue + + + +``` + +--- + +## 路由结构 + +``` +/login - 登录页 +/ - 主布局 + ├── / - 首页 + ├── /chat - 对话列表 + ├── /chat/:id - 对话详情 + ├── /constitution - 体质分析首页 + ├── /constitution/test - 体质问卷 + ├── /constitution/result - 体质结果 + ├── /profile - 个人中心 + └── /profile/health-record - 健康档案 +``` + +--- + +## 验收标准 + +- [ ] 路由配置正确 +- [ ] 布局显示正常 +- [ ] 导航切换正常 +- [ ] 登录状态守卫正常 + +--- + +## 预计耗时 + +25-30 分钟 + +--- + +## 下一步 + +完成后进入 `03-Web原型开发/03-登录页面.md` diff --git a/TODOS/03-Web原型开发/03-登录页面.md b/TODOS/03-Web原型开发/03-登录页面.md new file mode 100644 index 0000000..dc076a9 --- /dev/null +++ b/TODOS/03-Web原型开发/03-登录页面.md @@ -0,0 +1,271 @@ +# 03-登录页面(原型) + +## 目标 + +实现 Web 端登录页面原型,使用模拟数据验证登录。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/登录页.png` + +--- + +## 前置要求 + +- 路由配置完成 +- 模拟数据服务已创建 + +--- + +## 实施步骤 + +### 创建登录页面 + +创建 `src/views/auth/LoginView.vue`: + +```vue + + + + + +``` + +--- + +## 验收标准 + +- [ ] 登录页面 UI 正常显示 +- [ ] 验证码倒计时正常 +- [ ] 表单验证正常 +- [ ] 正确验证码可登录成功 +- [ ] 登录成功后跳转到首页 + +--- + +## 预计耗时 + +20-25 分钟 + +--- + +## 下一步 + +完成后进入 `03-Web原型开发/04-首页.md` diff --git a/TODOS/03-Web原型开发/04-首页.md b/TODOS/03-Web原型开发/04-首页.md new file mode 100644 index 0000000..7d57b99 --- /dev/null +++ b/TODOS/03-Web原型开发/04-首页.md @@ -0,0 +1,391 @@ +# 04-首页(原型) + +## 目标 + +实现 Web 首页原型,展示用户体质信息、快捷入口和健康提示。 + +--- + +## 前置要求 + +- 路由和布局配置完成 +- 模拟数据服务已创建 +- 登录页面完成 + +--- + +## 实施步骤 + +### 创建体质状态 Store + +创建 `src/stores/constitution.ts`: + +```typescript +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { ConstitutionResult } from '@/types' + +export const useConstitutionStore = defineStore('constitution', () => { + const result = ref(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 + + + + + +``` + +--- + +## 验收标准 + +- [ ] 首页 UI 正常显示 +- [ ] 体质卡片显示正确 +- [ ] 快捷入口点击跳转正常 +- [ ] 健康提示显示正常 +- [ ] 推荐产品显示正常 + +--- + +## 预计耗时 + +30-35 分钟 + +--- + +## 下一步 + +完成后进入 `03-Web原型开发/05-体质辨识页面.md` diff --git a/TODOS/03-Web原型开发/05-体质辨识页面.md b/TODOS/03-Web原型开发/05-体质辨识页面.md new file mode 100644 index 0000000..4f18dab --- /dev/null +++ b/TODOS/03-Web原型开发/05-体质辨识页面.md @@ -0,0 +1,626 @@ +# 05-体质辨识页面(原型) + +## 目标 + +实现 Web 端中医体质辨识问卷和结果展示,使用本地模拟数据计算结果。 + +--- + +## 页面组成 + +1. **体质首页** - 介绍页面,引导用户开始测试 +2. **问卷页面** - 60道题目,逐题作答 +3. **结果页面** - 显示体质类型、雷达图、调养建议 + +--- + +## 前置要求 + +- 路由配置完成 +- 模拟数据服务已创建 + +--- + +## 实施步骤 + +### 步骤 1:体质首页 + +创建 `src/views/constitution/ConstitutionView.vue`: + +```vue + + + + + +``` + +### 步骤 2:问卷页面 + +创建 `src/views/constitution/ConstitutionTestView.vue`: + +```vue + + + + + +``` + +### 步骤 3:结果页面 + +创建 `src/views/constitution/ConstitutionResultView.vue`: + +```vue + + + + + +``` + +--- + +## 验收标准 + +- [ ] 体质首页正常显示 +- [ ] 问卷60题可完整答题 +- [ ] 进度条显示正确 +- [ ] 提交后本地计算结果 +- [ ] 雷达图显示正常 +- [ ] 调理建议完整显示 + +--- + +## 预计耗时 + +45-55 分钟 + +--- + +## 下一步 + +完成后进入 `03-Web原型开发/06-AI对话页面.md` diff --git a/TODOS/03-Web原型开发/06-AI对话页面.md b/TODOS/03-Web原型开发/06-AI对话页面.md new file mode 100644 index 0000000..dfaf1c6 --- /dev/null +++ b/TODOS/03-Web原型开发/06-AI对话页面.md @@ -0,0 +1,573 @@ +# 06-AI对话页面(原型) + +## 目标 + +实现 Web 端 AI 健康问诊对话功能,使用模拟数据模拟多轮对话效果。 + +--- + +## 前置要求 + +- 路由配置完成 +- 模拟数据服务已创建 + +--- + +## 实施步骤 + +### 步骤 1:创建对话状态 Store + +创建 `src/stores/chat.ts`: + +```typescript +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Conversation, Message } from '@/types' + +export const useChatStore = defineStore('chat', () => { + const conversations = ref([]) + + 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 + + + + + +``` + +### 步骤 3:对话详情页面 + +创建 `src/views/chat/ChatDetailView.vue`: + +```vue + + + + + +``` + +--- + +## 验收标准 + +- [ ] 对话列表正常显示 +- [ ] 新建对话正常 +- [ ] 删除对话正常 +- [ ] 消息发送和模拟回复正常 +- [ ] 快捷问题点击正常 +- [ ] 免责声明显示 + +--- + +## 预计耗时 + +40-50 分钟 + +--- + +## 下一步 + +完成后进入 `03-Web原型开发/07-个人中心页面.md` diff --git a/TODOS/03-Web原型开发/07-个人中心页面.md b/TODOS/03-Web原型开发/07-个人中心页面.md new file mode 100644 index 0000000..476371e --- /dev/null +++ b/TODOS/03-Web原型开发/07-个人中心页面.md @@ -0,0 +1,462 @@ +# 07-个人中心页面(原型) + +## 目标 + +实现 Web 端个人中心和健康档案管理页面原型。 + +--- + +## 前置要求 + +- 路由配置完成 +- 认证状态 Store 已创建 +- 体质状态 Store 已创建 + +--- + +## 实施步骤 + +### 步骤 1:个人中心页面 + +创建 `src/views/profile/ProfileView.vue`: + +```vue + + + + + +``` + +### 步骤 2:健康档案页面 + +创建 `src/views/profile/HealthRecordView.vue`: + +```vue + + + + + +``` + +--- + +## 验收标准 + +- [ ] 个人中心页面正常显示 +- [ ] 菜单跳转正常 +- [ ] 退出登录正常 +- [ ] 健康档案页面正常显示 +- [ ] 体质信息正确展示 + +--- + +## 预计耗时 + +30-35 分钟 + +--- + +## 完成 + +至此,Web 原型开发所有页面文档创建完成! + +可以开始第四阶段:后端开发。 diff --git a/TODOS/04-APP开发/01-项目结构初始化.md b/TODOS/04-APP开发/01-项目结构初始化.md new file mode 100644 index 0000000..72acef9 --- /dev/null +++ b/TODOS/04-APP开发/01-项目结构初始化.md @@ -0,0 +1,377 @@ +# 01-APP React Native 项目结构初始化 + +## 目标 + +使用 React Native CLI 创建项目,配置基础依赖和目录结构。 + +--- + +## 前置要求 + +- React Native 环境已搭建 +- Node.js 18+ 已安装 +- Android Studio / Xcode 已配置 + +--- + +## 实施步骤 + +### 步骤 1:创建 React Native 项目 + +```bash +cd I:\apps\demo\healthApps + +# 创建项目 +npx react-native init app --template react-native-template-typescript + +cd app +``` + +### 步骤 2:安装核心依赖 + +```bash +# 导航 +npm install @react-navigation/native @react-navigation/native-stack @react-navigation/bottom-tabs +npm install react-native-screens react-native-safe-area-context + +# 状态管理 +npm install zustand + +# 网络请求 +npm install axios + +# 图表(体质雷达图) +npm install react-native-svg +npm install react-native-gifted-charts + +# 存储 +npm install @react-native-async-storage/async-storage + +# 表单 +npm install react-hook-form + +# UI 组件 +npm install react-native-paper react-native-vector-icons +npm install @types/react-native-vector-icons -D + +# 工具 +npm install dayjs +``` + +### 步骤 3:配置 iOS 依赖(macOS) + +```bash +cd ios +pod install +cd .. +``` + +### 步骤 4:创建目录结构 + +```bash +mkdir -p src/api +mkdir -p src/components/common +mkdir -p src/components/survey +mkdir -p src/components/constitution +mkdir -p src/components/chat +mkdir -p src/screens/auth +mkdir -p src/screens/survey +mkdir -p src/screens/constitution +mkdir -p src/screens/chat +mkdir -p src/screens/profile +mkdir -p src/navigation +mkdir -p src/stores +mkdir -p src/hooks +mkdir -p src/utils +mkdir -p src/types +mkdir -p src/assets +``` + +### 步骤 5:创建类型定义 + +创建 `src/types/index.ts`: +```typescript +export interface User { + id: number + phone: string + email: string + nickname: string + avatar: string + survey_completed: boolean +} + +export interface HealthProfile { + id: number + name: string + birth_date: string + gender: string + height: number + weight: number + bmi: number + blood_type: string +} + +export interface Question { + id: number + constitution_type: string + question_text: string + options: string[] + order_num: number +} + +export interface ConstitutionScore { + type: string + name: string + score: number + description: string +} + +export interface ConstitutionResult { + primary_constitution: ConstitutionScore + secondary_constitutions: ConstitutionScore[] + all_scores: ConstitutionScore[] + recommendations: Record> + 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 { + 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 +} + +export const useUserStore = create((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 ( + + + + + + + + + ) +} + +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` diff --git a/TODOS/04-APP开发/02-导航和布局设计.md b/TODOS/04-APP开发/02-导航和布局设计.md new file mode 100644 index 0000000..4846bf8 --- /dev/null +++ b/TODOS/04-APP开发/02-导航和布局设计.md @@ -0,0 +1,448 @@ +# 02-导航和布局设计 + +## 目标 + +配置 React Navigation 导航系统,实现 Tab 导航和 Stack 导航。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/首页.png` + +### 底部 Tab 导航规范 + +| Tab | 图标 | 文字 | +|-----|------|------| +| 首页 | 房屋图标 `home` | 首页 | +| AI问答 | 对话气泡 `chat-processing` | AI问答 | +| 体质分析 | 心电图 `chart-line-variant` | 体质分析 | +| 我的 | 用户图标 `account` | 我的 | + +### Tab 样式规范 + +```typescript +const tabBarOptions = { + activeTintColor: '#10B981', // 选中颜色 + inactiveTintColor: '#9CA3AF', // 未选中颜色 + style: { + height: 60, + paddingBottom: 8, + paddingTop: 8, + backgroundColor: '#FFFFFF', + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + }, + labelStyle: { + fontSize: 12, + }, +} +``` + +### 导航栏样式 + +```typescript +const headerOptions = { + headerStyle: { + backgroundColor: '#10B981', + }, + headerTintColor: '#FFFFFF', + headerTitleStyle: { + fontWeight: '600', + }, +} +``` + +--- + +## 前置要求 + +- 项目结构已初始化 +- React Navigation 已安装 + +--- + +## 实施步骤 + +### 步骤 1:创建导航类型定义 + +创建 `src/navigation/types.ts`: +```typescript +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' +import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs' +import type { CompositeNavigationProp, RouteProp } from '@react-navigation/native' + +// Root Stack +export type RootStackParamList = { + Auth: undefined + Main: undefined + Survey: undefined +} + +// Auth Stack +export type AuthStackParamList = { + Login: undefined + Register: undefined +} + +// Main Tab +export type MainTabParamList = { + ChatTab: undefined + ConstitutionTab: undefined + ProfileTab: undefined +} + +// Chat Stack +export type ChatStackParamList = { + ChatList: undefined + ChatDetail: { id: number } +} + +// Constitution Stack +export type ConstitutionStackParamList = { + ConstitutionHome: undefined + ConstitutionQuestions: undefined + ConstitutionResult: undefined +} + +// Profile Stack +export type ProfileStackParamList = { + ProfileHome: undefined + HealthRecord: undefined +} + +// Navigation Props +export type RootNavigationProp = NativeStackNavigationProp + +export type AuthNavigationProp = CompositeNavigationProp< + NativeStackNavigationProp, + NativeStackNavigationProp +> + +export type MainTabNavigationProp = BottomTabNavigationProp + +export type ChatNavigationProp = CompositeNavigationProp< + NativeStackNavigationProp, + MainTabNavigationProp +> + +// Route Props +export type ChatDetailRouteProp = RouteProp +``` + +### 步骤 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() + +const AuthNavigator = () => { + return ( + + + + + ) +} + +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() + +const MainTabNavigator = () => { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ) +} + +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() + +const ChatNavigator = () => { + return ( + + + + + ) +} + +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() + +const ConstitutionNavigator = () => { + return ( + + + + + + ) +} + +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() + +const ProfileNavigator = () => { + return ( + + + + + ) +} + +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() + +const RootNavigator = () => { + const { isLoggedIn, surveyCompleted } = useUserStore() + + return ( + + {!isLoggedIn ? ( + + ) : !surveyCompleted ? ( + + ) : ( + + )} + + ) +} + +export default RootNavigator +``` + +### 步骤 6:创建占位页面 + +创建基础的占位页面组件,后续会详细实现。 + +创建 `src/screens/auth/LoginScreen.tsx`: +```typescript +import React from 'react' +import { View, Text, StyleSheet } from 'react-native' + +const LoginScreen = () => { + return ( + + 登录页面(待实现) + + ) +} + +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` diff --git a/TODOS/04-APP开发/03-用户认证页面.md b/TODOS/04-APP开发/03-用户认证页面.md new file mode 100644 index 0000000..49cf192 --- /dev/null +++ b/TODOS/04-APP开发/03-用户认证页面.md @@ -0,0 +1,535 @@ +# 03-用户认证页面 + +## 目标 + +实现 APP 端登录和注册页面。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/登录页.png` + +### 页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色渐变背景 (`#10B981 → #2EC4B6`) + 医疗插图 | +| Logo | "AI健康助手" 标题(白色 32px)+ slogan | +| 表单卡片 | 白色背景,圆角 16px | +| 输入框 | 圆角 12px,左侧带图标 | +| 主按钮 | 绿色 `#10B981`,圆角 24px,高度 48px | + +### 样式常量 + +```typescript +const colors = { + primary: '#10B981', + primaryDark: '#059669', + background: '#10B981', + cardBackground: '#FFFFFF', + inputBackground: '#F3F4F6', + textPrimary: '#1F2937', + textSecondary: '#6B7280', + textHint: '#9CA3AF', +} + +const spacing = { + screenPadding: 20, + cardPadding: 24, + inputMargin: 16, +} + +const borderRadius = { + card: 16, + input: 12, + button: 24, +} +``` + +--- + +## 前置要求 + +- 导航配置完成 +- 后端认证接口可用 + +--- + +## 实施步骤 + +### 步骤 1:创建认证 API + +创建 `src/api/auth.ts`: +```typescript +import request from './request' + +export interface LoginRequest { + phone: string + password: string +} + +export interface RegisterRequest { + phone: string + password: string + nickname?: string +} + +export interface AuthResponse { + token: string + user_id: number + nickname: string + survey_completed: boolean +} + +export const login = (data: LoginRequest): Promise => { + return request.post('/auth/login', data) +} + +export const register = (data: RegisterRequest): Promise => { + 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() + 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 ( + + + + 健康AI助手 + 您的智能健康管家 + + + + } + /> + + } + right={ + setShowPassword(!showPassword)} + /> + } + /> + + + + + 还没有账号? + + + + + + ) +} + +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() + 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 ( + + + + 创建账号 + + + + } + /> + + } + /> + + } + right={ + setShowPassword(!showPassword)} + /> + } + /> + + } + /> + + + setAgreement(!agreement)} + /> + + 我已阅读并同意《用户协议》和《隐私政策》 + + + + + + + 已有账号? + + + + + + ) +} + +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 diff --git a/TODOS/04-APP开发/04-健康调查页面.md b/TODOS/04-APP开发/04-健康调查页面.md new file mode 100644 index 0000000..44ab615 --- /dev/null +++ b/TODOS/04-APP开发/04-健康调查页面.md @@ -0,0 +1,238 @@ +# 04-健康调查页面 + +## 目标 + +实现 APP 端新用户健康调查功能,包括多步骤表单。 + +--- + +## 实现要点 + +APP 端健康调查与 Web 端功能相同,主要差异: + +1. **步骤指示器**:使用自定义组件或 `react-native-paper` 的 ProgressBar +2. **表单输入**:使用 `TextInput`、`RadioButton`、`Switch` 等 RN 组件 +3. **日期选择**:使用 `@react-native-community/datetimepicker` +4. **滑动选择**:使用 `@react-native-community/slider` + +--- + +## 关键代码示例 + +### 步骤指示器组件 + +```typescript +// src/components/common/StepIndicator.tsx +import React from 'react' +import { View, Text, StyleSheet } from 'react-native' + +interface Props { + steps: string[] + currentStep: number +} + +const StepIndicator: React.FC = ({ steps, currentStep }) => { + return ( + + {steps.map((step, index) => ( + + + + {index + 1} + + + {step} + {index < steps.length - 1 && ( + + )} + + ))} + + ) +} + +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 ( + + + + + {currentStep === 0 && } + {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} + {currentStep === 3 && ( + + 健康调查完成! + + 接下来进行体质测评 + + + + )} + + + ) +} + +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` diff --git a/TODOS/04-APP开发/05-体质辨识页面.md b/TODOS/04-APP开发/05-体质辨识页面.md new file mode 100644 index 0000000..91f2c55 --- /dev/null +++ b/TODOS/04-APP开发/05-体质辨识页面.md @@ -0,0 +1,471 @@ +# 05-体质辨识页面 + +## 目标 + +实现 APP 端中医体质辨识问卷和结果展示功能。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/体质页.png`、`files/ui/体质检测.png`、`files/ui/体质分析.png` + +### 体质首页 + +| 区域 | 设计要点 | +|------|----------| +| 顶部卡片 | 绿色渐变背景 `#10B981 → #2EC4B6`,圆角 16px | +| 标题 | "中医体质自测",白色 20px 粗体 | +| 测试说明 | 白色卡片,3步骤(绿色序号圆圈) | +| 按钮 | 绿色全宽按钮,圆角 24px | + +### 问卷页面 + +| 区域 | 设计要点 | +|------|----------| +| 进度显示 | 右上角 "1/65",绿色进度条高度 4px | +| 问题标签 | 绿色背景 `#DCFCE7`,文字 `#10B981` | +| 选项按钮 | 白色背景,边框 `#E5E7EB`,选中时绿色边框 + 背景 | +| 底部提示 | 浅绿色背景 `#ECFDF5`,info 图标 | + +### 结果页面 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色渐变 + 人体轮廓 + 分享按钮 | +| 体质名称 | 白色大字 32px,分数徽章 | +| 雷达图 | 白色卡片,颜色 `#10B981`,透明填充 | +| 调理建议 | 2×2 网格,各带彩色图标背景 | + +### 调理建议卡片 + +| 类型 | 图标背景 | 图标颜色 | +|------|----------|----------| +| 起居 | `#EDE9FE` | `#8B5CF6` | +| 饮食 | `#CCFBF1` | `#14B8A6` | +| 运动 | `#EDE9FE` | `#8B5CF6` | +| 情志 | `#FCE7F3` | `#EC4899` | + +--- + +## 实现要点 + +1. **问卷 UI**:使用卡片+按钮组形式展示题目和选项 +2. **进度条**:使用 `ProgressBar` 显示答题进度 +3. **雷达图**:使用 `react-native-gifted-charts` 或 `victory-native` +4. **结果展示**:使用 Tab 切换调养建议 + +--- + +## 关键代码示例 + +### 问卷页面 + +```typescript +// src/screens/constitution/ConstitutionQuestionsScreen.tsx +import React, { useState, useEffect } from 'react' +import { View, Text, ScrollView, StyleSheet } from 'react-native' +import { Button, ProgressBar, Card } from 'react-native-paper' +import { useNavigation } from '@react-navigation/native' +import { getQuestions, submitAssessment } from '../../api/constitution' +import type { Question } from '../../types' + +const options = ['没有', '很少', '有时', '经常', '总是'] + +const ConstitutionQuestionsScreen = () => { + const navigation = useNavigation() + const [questions, setQuestions] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [answers, setAnswers] = useState>({}) + 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 ( + + 加载中... + + ) + } + + return ( + + + + {currentIndex + 1} / {questions.length} + + + + + + + + + {currentIndex + 1}. {currentQuestion.question_text} + + + + {options.map((option, index) => ( + + ))} + + + + + + + + + {currentIndex < questions.length - 1 ? ( + + ) : ( + + )} + + + ) +} + +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(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 ( + + 加载中... + + ) + } + + return ( + + {/* 主要体质 */} + + + 您的体质类型 + + {result.primary_constitution.name} + + + {result.primary_constitution.description} + + + + + {/* 所有体质得分 */} + + + 体质得分 + {result.all_scores.map((score) => ( + + {score.name} + + + + {score.score.toFixed(0)} + + ))} + + + + {/* 调养建议 */} + + + 调养建议 + {Object.entries(result.recommendations).map(([type, recs]) => ( + + {Object.entries(recs).map(([key, value]) => ( + + + {key === 'diet' ? '饮食' : key === 'lifestyle' ? '起居' : key === 'exercise' ? '运动' : '情志'} + + {value} + + ))} + + ))} + + + + + + + + ) +} + +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` diff --git a/TODOS/04-APP开发/06-AI对话页面.md b/TODOS/04-APP开发/06-AI对话页面.md new file mode 100644 index 0000000..baf5408 --- /dev/null +++ b/TODOS/04-APP开发/06-AI对话页面.md @@ -0,0 +1,476 @@ +# 06-AI对话页面 + +## 目标 + +实现 APP 端 AI 健康问诊对话功能。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/问答页.png`、`files/ui/问答对话.png` + +### 对话首页 + +| 区域 | 设计要点 | +|------|----------| +| 导航栏 | "AI健康助手" + 绿色 "在线" 状态 | +| AI 欢迎语 | 机器人图标(蓝色 `#3B82F6`)+ 灰色气泡 | +| 常见问题 | 灰色标签 + 白色圆角按钮(多个快捷问题) | +| 输入区 | 麦克风 + 输入框 + 绿色发送按钮 | + +### 消息气泡 + +| 类型 | 样式 | +|------|------| +| AI 消息 | 左对齐,机器人图标蓝色 `#3B82F6`,气泡白色 `#FFFFFF` | +| 用户消息 | 右对齐,用户图标绿色 `#10B981`,气泡绿色 `#10B981`,文字白色 | +| 时间 | 灰色 `#9CA3AF`,位于气泡下方 | + +### 样式常量 + +```typescript +const chatStyles = { + // 气泡 + userBubble: { + backgroundColor: '#10B981', + borderRadius: 16, + borderBottomRightRadius: 4, + }, + assistantBubble: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + borderBottomLeftRadius: 4, + }, + // 头像 + userAvatar: { + backgroundColor: '#10B981', + }, + aiAvatar: { + backgroundColor: '#3B82F6', + }, + // 输入区 + inputContainer: { + backgroundColor: '#FFFFFF', + borderTopColor: '#E5E7EB', + }, + sendButton: { + backgroundColor: '#10B981', + borderRadius: 20, + }, +} +``` + +--- + +## 实现要点 + +1. **消息列表**:使用 FlatList 渲染消息,支持自动滚动到底部 +2. **输入框**:固定在底部,支持多行输入 +3. **键盘适配**:使用 KeyboardAvoidingView 处理键盘弹出 +4. **消息气泡**:区分用户和 AI 消息样式 + +--- + +## 关键代码示例 + +### 对话列表页面 + +```typescript +// src/screens/chat/ChatListScreen.tsx +import React, { useState, useEffect } from 'react' +import { View, FlatList, StyleSheet, TouchableOpacity } from 'react-native' +import { Text, FAB, Card, IconButton } from 'react-native-paper' +import { useNavigation } from '@react-navigation/native' +import dayjs from 'dayjs' +import { getConversations, createConversation, deleteConversation } from '../../api/conversation' +import type { Conversation } from '../../types' +import type { ChatNavigationProp } from '../../navigation/types' + +const ChatListScreen = () => { + const navigation = useNavigation() + const [conversations, setConversations] = useState([]) + 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 }) => ( + navigation.navigate('ChatDetail', { id: item.id })} + > + + + + {item.title} + + {dayjs(item.updated_at).format('MM-DD HH:mm')} + + + handleDelete(item.id)} + /> + + + + ) + + return ( + + {conversations.length === 0 ? ( + + 暂无对话记录 + 点击下方按钮开始第一次对话 + + ) : ( + item.id.toString()} + contentContainerStyle={styles.list} + /> + )} + + + + ) +} + +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() + const { id } = route.params + const { user } = useUserStore() + const flatListRef = useRef(null) + + const [messages, setMessages] = useState([]) + 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 ( + + {!isUser && ( + + )} + + + {item.content} + + + {isUser && ( + + )} + + ) + } + + return ( + + item.id.toString()} + contentContainerStyle={styles.messageList} + onContentSizeChange={() => flatListRef.current?.scrollToEnd()} + /> + + {sending && ( + + AI 正在输入... + + )} + + + + + + + + + AI 建议仅供参考,不构成医疗诊断 + + + + ) +} + +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` diff --git a/TODOS/04-APP开发/07-个人中心页面.md b/TODOS/04-APP开发/07-个人中心页面.md new file mode 100644 index 0000000..324e919 --- /dev/null +++ b/TODOS/04-APP开发/07-个人中心页面.md @@ -0,0 +1,494 @@ +# 07-个人中心页面 + +## 目标 + +实现 APP 端个人中心和健康档案管理页面。 + +--- + +## UI 设计参考 + +> 参考设计稿:`files/ui/我的.png` + +### 页面布局 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色背景 `#10B981` + "我的" 标题(白色) | +| 用户卡片 | 头像(64px 圆形)+ 姓名 + 基本信息 + 用户ID | +| 编辑按钮 | 白色半透明背景,编辑图标 | +| 健康管理 | "用药情况" 入口(带角标 "12条") | +| 设置列表 | 消息通知、隐私设置、通用设置 | + +### 用户卡片样式 + +```typescript +const userCardStyles = { + container: { + backgroundColor: '#10B981', + padding: 20, + borderRadius: 16, + }, + avatar: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: 'rgba(255,255,255,0.2)', + }, + nickname: { + color: '#FFFFFF', + fontSize: 20, + fontWeight: '600', + }, + basicInfo: { + color: '#FFFFFF', + fontSize: 14, + }, + userId: { + color: 'rgba(255,255,255,0.7)', + fontSize: 12, + }, +} +``` + +### 列表项样式 + +| 元素 | 样式 | +|------|------| +| 图标背景 | 40px 圆形 | +| 用药情况 | `#DCFCE7` 背景,`#10B981` 图标 | +| 消息通知 | `#DCFCE7` 背景,铃铛图标 | +| 隐私设置 | `#DCFCE7` 背景,盾牌图标 | +| 通用设置 | `#DCFCE7` 背景,齿轮图标 | +| 角标 | 灰色文字 `#6B7280` | +| 右箭头 | 灰色 `#9CA3AF` | + +```typescript +const listItemStyles = { + container: { + backgroundColor: '#FFFFFF', + paddingVertical: 16, + paddingHorizontal: 16, + borderRadius: 12, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#DCFCE7', + justifyContent: 'center', + alignItems: 'center', + }, + iconColor: '#10B981', + title: { + fontSize: 16, + color: '#1F2937', + }, + description: { + fontSize: 13, + color: '#6B7280', + }, + badge: { + fontSize: 13, + color: '#6B7280', + }, +} +``` + +--- + +## 实现要点 + +1. **用户信息卡片**:显示头像、昵称、手机号 +2. **功能菜单列表**:使用 List 组件展示菜单项 +3. **健康档案**:展示基础信息、体质、病史等 + +--- + +## 关键代码示例 + +### 个人中心页面 + +```typescript +// src/screens/profile/ProfileHomeScreen.tsx +import React from 'react' +import { View, ScrollView, StyleSheet, Alert } from 'react-native' +import { Text, Avatar, Card, List, Button, Divider } from 'react-native-paper' +import { useNavigation } from '@react-navigation/native' +import { useUserStore } from '../../stores/userStore' +import type { ProfileStackParamList } from '../../navigation/types' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' + +type NavigationProp = NativeStackNavigationProp + +const ProfileHomeScreen = () => { + const navigation = useNavigation() + const { user, logout } = useUserStore() + + const handleLogout = () => { + Alert.alert('提示', '确定要退出登录吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '确定', + style: 'destructive', + onPress: () => logout(), + }, + ]) + } + + return ( + + {/* 用户信息卡片 */} + + + + + {user?.nickname || '用户'} + {user?.phone} + + + + + {/* 功能菜单 */} + + } + right={(props) => } + onPress={() => navigation.navigate('HealthRecord')} + /> + + } + right={(props) => } + onPress={() => navigation.getParent()?.navigate('ConstitutionTab')} + /> + + } + right={(props) => } + onPress={() => navigation.getParent()?.navigate('ConstitutionTab', { + screen: 'ConstitutionQuestions', + })} + /> + + } + right={(props) => } + onPress={() => + Alert.alert( + '关于我们', + '健康AI助手是一款智能健康咨询应用,结合中医体质辨识理论,为您提供个性化的健康建议。\n\n版本:1.0.0' + ) + } + /> + + + {/* 退出登录 */} + + + + + ) +} + +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 = { + male: '男', + female: '女', +} + +const HealthRecordScreen = () => { + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadProfile() + }, []) + + const loadProfile = async () => { + try { + const data = await getHealthProfile() + setProfile(data) + } finally { + setLoading(false) + } + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 基础信息 */} + + + + {profile?.basic_info ? ( + + + + + + + + + ) : ( + 暂无基础信息 + )} + + + + {/* 体质信息 */} + + + + {profile?.constitution ? ( + + + {profile.constitution.primary_name} + + + {profile.constitution.primary_description} + + + 测评时间:{profile.constitution.assessed_at} + + + ) : ( + 暂无体质测评记录 + )} + + + + {/* 既往病史 */} + + + + {profile?.medical_history?.length > 0 ? ( + + {profile.medical_history.map((item: any) => ( + + {item.disease_name} + + ))} + + ) : ( + 暂无病史记录 + )} + + + + {/* 过敏信息 */} + + + + {profile?.allergy_records?.length > 0 ? ( + + {profile.allergy_records.map((item: any) => ( + + {item.allergen} + + ))} + + ) : ( + 暂无过敏信息 + )} + + + + ) +} + +const InfoItem = ({ label, value }: { label: string; value?: string }) => ( + + {label} + {value || '-'} + +) + +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. **发布**:提交到应用商店审核 diff --git a/TODOS/04-后端开发/01-项目结构初始化.md b/TODOS/04-后端开发/01-项目结构初始化.md new file mode 100644 index 0000000..57953ed --- /dev/null +++ b/TODOS/04-后端开发/01-项目结构初始化.md @@ -0,0 +1,185 @@ +# 01-后端项目结构初始化 + +## 目标 + +创建 Go + Gin 后端项目的基础目录结构和配置文件。 + +--- + +## 前置要求 + +- Go 1.21+ 已安装 +- 环境变量已配置 + +--- + +## 实施步骤 + +### 步骤 1:创建项目目录 + +```bash +cd I:\apps\demo\healthApps +mkdir -p server +cd server +``` + +### 步骤 2:初始化 Go 模块 + +```bash +go mod init health-ai +``` + +### 步骤 3:创建目录结构 + +```bash +mkdir -p cmd/server +mkdir -p internal/api/handler +mkdir -p internal/api/middleware +mkdir -p internal/model +mkdir -p internal/service +mkdir -p internal/repository/impl +mkdir -p internal/config +mkdir -p internal/database +mkdir -p pkg/jwt +mkdir -p pkg/response +mkdir -p pkg/utils +mkdir -p data +``` + +### 步骤 4:安装核心依赖 + +```bash +# Web 框架 +go get -u github.com/gin-gonic/gin + +# ORM +go get -u gorm.io/gorm +go get -u gorm.io/driver/sqlite + +# 配置管理 +go get -u github.com/spf13/viper + +# 日志 +go get -u go.uber.org/zap + +# JWT +go get -u github.com/golang-jwt/jwt/v5 + +# 密码加密 +go get -u golang.org/x/crypto/bcrypt + +# 参数验证 +go get -u github.com/go-playground/validator/v10 + +# 跨域 +go get -u github.com/gin-contrib/cors +``` + +### 步骤 5:创建配置文件 + +创建 `server/config.yaml`: +```yaml +server: + port: 8080 + mode: debug # debug, release, test + +database: + driver: sqlite # sqlite, postgres, mysql + sqlite: + path: ./data/health.db + postgres: + host: localhost + port: 5432 + user: postgres + password: "" + dbname: health_app + mysql: + host: localhost + port: 3306 + user: root + password: "" + dbname: health_app + +jwt: + secret: your-secret-key-change-in-production + expire_hours: 24 + +ai: + provider: openai # openai, qwen + api_key: "" + base_url: "" +``` + +### 步骤 6:创建入口文件 + +创建 `server/cmd/server/main.go`: +```go +package main + +import ( + "log" +) + +func main() { + log.Println("Health AI Server Starting...") + // TODO: 初始化配置、数据库、路由 +} +``` + +### 步骤 7:验证项目 + +```bash +cd server +go mod tidy +go run cmd/server/main.go +# 输出: Health AI Server Starting... +``` + +--- + +## 最终目录结构 + +``` +server/ +├── cmd/ +│ └── server/ +│ └── main.go +├── internal/ +│ ├── api/ +│ │ ├── handler/ +│ │ └── middleware/ +│ ├── model/ +│ ├── service/ +│ ├── repository/ +│ │ └── impl/ +│ ├── config/ +│ └── database/ +├── pkg/ +│ ├── jwt/ +│ ├── response/ +│ └── utils/ +├── data/ +├── config.yaml +├── go.mod +└── go.sum +``` + +--- + +## 验收标准 + +- [ ] 目录结构创建完成 +- [ ] `go mod tidy` 无报错 +- [ ] `go run cmd/server/main.go` 正常输出 + +--- + +## 预计耗时 + +10-15 分钟 + +--- + +## 下一步 + +完成后进入 `04-后端开发/02-数据库和模型设计.md` diff --git a/TODOS/04-后端开发/02-数据库和模型设计.md b/TODOS/04-后端开发/02-数据库和模型设计.md new file mode 100644 index 0000000..2599587 --- /dev/null +++ b/TODOS/04-后端开发/02-数据库和模型设计.md @@ -0,0 +1,51 @@ +# 02-数据库和模型设计 + +## 目标 + +实现数据库连接模块和所有数据模型定义,支持多数据库切换。 + +--- + +## 前置要求 + +- 项目结构已初始化 +- 依赖已安装 + +--- + +## 实施步骤 + +详细代码请参考原文档 `02-后端开发/02-数据库和模型设计.md` + +### 主要任务 + +1. 创建配置加载模块 `internal/config/config.go` +2. 创建数据库连接模块 `internal/database/database.go` +3. 创建数据模型: + - `internal/model/user.go` - 用户、健康档案、生活习惯 + - `internal/model/health.go` - 病史、家族史、过敏记录 + - `internal/model/constitution.go` - 体质测评 + - `internal/model/conversation.go` - 对话消息 +4. 创建模型聚合文件 `internal/model/models.go` +5. 更新主程序初始化数据库 + +--- + +## 验收标准 + +- [ ] 配置文件可正常加载 +- [ ] SQLite 数据库文件自动创建 +- [ ] 所有表自动迁移成功 +- [ ] `data/health.db` 文件生成 + +--- + +## 预计耗时 + +20-30 分钟 + +--- + +## 下一步 + +完成后进入 `04-后端开发/03-用户认证模块.md` diff --git a/TODOS/04-后端开发/03-用户认证模块.md b/TODOS/04-后端开发/03-用户认证模块.md new file mode 100644 index 0000000..6439cb2 --- /dev/null +++ b/TODOS/04-后端开发/03-用户认证模块.md @@ -0,0 +1,59 @@ +# 03-用户认证模块 + +## 目标 + +实现用户注册、登录、Token 刷新等认证功能。 + +--- + +## 前置要求 + +- 数据库和模型已完成 +- JWT 依赖已安装 + +--- + +## 实施步骤 + +详细代码请参考原文档 `02-后端开发/03-用户认证模块.md` + +### 主要任务 + +1. 创建统一响应工具 `pkg/response/response.go` +2. 创建 JWT 工具 `pkg/jwt/jwt.go` +3. 创建认证中间件 `internal/api/middleware/auth.go` +4. 创建用户 Repository `internal/repository/impl/user.go` +5. 创建认证 Service `internal/service/auth.go` +6. 创建认证 Handler `internal/api/handler/auth.go` +7. 创建路由配置 `internal/api/router.go` +8. 更新主程序启动服务 + +--- + +## API 接口 + +| 方法 | 路径 | 说明 | +|-----|------|------| +| POST | /api/auth/register | 用户注册 | +| POST | /api/auth/login | 用户登录 | + +--- + +## 验收标准 + +- [ ] 服务启动成功,监听 8080 端口 +- [ ] `/health` 返回 `{"status": "ok"}` +- [ ] 注册接口正常创建用户 +- [ ] 登录接口返回有效 Token + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +完成后进入 `04-后端开发/04-健康调查模块.md` diff --git a/TODOS/04-后端开发/04-健康调查模块.md b/TODOS/04-后端开发/04-健康调查模块.md new file mode 100644 index 0000000..67bd45e --- /dev/null +++ b/TODOS/04-后端开发/04-健康调查模块.md @@ -0,0 +1,60 @@ +# 04-健康调查模块 + +## 目标 + +实现新用户健康调查功能,包括基础信息、生活习惯、病史、过敏史等信息的提交和管理。 + +--- + +## 前置要求 + +- 用户认证模块已完成 +- 数据模型已定义 + +--- + +## 实施步骤 + +详细代码请参考原文档 `02-后端开发/04-健康调查模块.md` + +### 主要任务 + +1. 创建健康档案 Repository `internal/repository/impl/health.go` +2. 创建健康调查 Service `internal/service/survey.go` +3. 创建健康调查 Handler `internal/api/handler/survey.go` +4. 更新路由配置 + +--- + +## API 接口 + +| 方法 | 路径 | 说明 | +|-----|------|------| +| GET | /api/survey/status | 获取调查完成状态 | +| POST | /api/survey/basic-info | 提交基础信息 | +| POST | /api/survey/lifestyle | 提交生活习惯 | +| POST | /api/survey/medical-history | 提交病史 | +| POST | /api/survey/family-history | 提交家族病史 | +| POST | /api/survey/allergy | 提交过敏信息 | + +--- + +## 验收标准 + +- [ ] 获取调查状态接口正常 +- [ ] 基础信息提交成功,BMI 自动计算 +- [ ] 生活习惯提交成功 +- [ ] 病史、家族史、过敏信息可多次添加 +- [ ] 所有接口需要认证 + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +完成后进入 `04-后端开发/05-体质辨识模块.md` diff --git a/TODOS/04-后端开发/05-体质辨识模块.md b/TODOS/04-后端开发/05-体质辨识模块.md new file mode 100644 index 0000000..c8e3de8 --- /dev/null +++ b/TODOS/04-后端开发/05-体质辨识模块.md @@ -0,0 +1,83 @@ +# 05-体质辨识模块 + +## 目标 + +实现中医体质辨识问卷功能,包括问卷题库、答案提交、体质计算和调养建议生成。 + +--- + +## 前置要求 + +- 健康调查模块已完成 +- 数据模型已定义 + +--- + +## 实施步骤 + +详细代码请参考原文档 `02-后端开发/05-体质辨识模块.md` + +### 主要任务 + +1. 创建体质常量定义 `internal/model/constitution_const.go` + - 九种体质类型常量 + - 体质名称和特征描述 + - 体质调养建议 + +2. 创建问卷题库初始化 `internal/database/seed.go` + - 60+ 道问卷题目 + - 按体质类型分组 + +3. 创建体质 Repository `internal/repository/impl/constitution.go` + +4. 创建体质计算 Service `internal/service/constitution.go` + - 分数计算算法 + - 体质判定逻辑(平和质特殊判定) + +5. 创建体质 Handler 并更新路由 + +--- + +## API 接口 + +| 方法 | 路径 | 说明 | +|-----|------|------| +| GET | /api/constitution/questions | 获取问卷题目 | +| POST | /api/constitution/submit | 提交问卷答案 | +| GET | /api/constitution/result | 获取最新结果 | +| GET | /api/constitution/history | 获取测评历史 | + +--- + +## 体质计算公式 + +``` +转化分 = (原始分 - 条目数) / (条目数 × 4) × 100 +``` + +### 判定规则 + +- 平和质:平和质得分 ≥ 60 且其他体质 < 30 +- 偏颇体质:得分 ≥ 40 为主要体质,≥ 30 为次要体质 + +--- + +## 验收标准 + +- [ ] 问卷题库自动初始化(60+ 题) +- [ ] 获取问卷接口返回所有题目 +- [ ] 提交答案后正确计算体质得分 +- [ ] 体质判定逻辑正确 +- [ ] 调养建议正确返回 + +--- + +## 预计耗时 + +40-50 分钟 + +--- + +## 下一步 + +完成后进入 `04-后端开发/06-AI对话模块.md` diff --git a/TODOS/04-后端开发/06-AI对话模块.md b/TODOS/04-后端开发/06-AI对话模块.md new file mode 100644 index 0000000..5c8e1e0 --- /dev/null +++ b/TODOS/04-后端开发/06-AI对话模块.md @@ -0,0 +1,96 @@ +# 06-AI对话模块 + +## 目标 + +实现 AI 健康问诊对话功能,支持多轮对话、结合用户体质信息、流式响应。 + +--- + +## 前置要求 + +- 体质辨识模块已完成 +- 已有 AI API Key(OpenAI / 通义千问) + +--- + +## 实施步骤 + +详细代码请参考原文档 `02-后端开发/06-AI对话模块.md` + +### 主要任务 + +1. 创建 AI 客户端抽象 `internal/service/ai/client.go` +2. 实现 OpenAI 客户端 `internal/service/ai/openai.go` +3. 实现阿里云通义千问客户端 `internal/service/ai/aliyun.go` +4. 创建 AI 客户端工厂 `internal/service/ai/factory.go` +5. 创建对话 Repository `internal/repository/impl/conversation.go` +6. 创建对话 Service `internal/service/conversation.go` + - 系统提示词模板 + - 用户体质信息注入 + - 产品推荐整合 +7. 创建对话 Handler 并更新路由 + +--- + +## AI 配置 + +```yaml +ai: + provider: aliyun # openai, aliyun + max_history_messages: 10 + + openai: + api_key: "sk-xxx" + base_url: "https://api.openai.com/v1" + model: "gpt-3.5-turbo" + + aliyun: + api_key: "sk-xxx" + model: "qwen-turbo" +``` + +--- + +## API 接口 + +| 方法 | 路径 | 说明 | +|-----|------|------| +| GET | /api/conversations | 获取对话列表 | +| POST | /api/conversations | 创建新对话 | +| GET | /api/conversations/:id | 获取对话详情 | +| DELETE | /api/conversations/:id | 删除对话 | +| POST | /api/conversations/:id/messages | 发送消息 | + +--- + +## 系统提示词要点 + +1. 角色定义:健康咨询助理(非医师) +2. 紧急情况:胸痛、高烧等必须建议就医 +3. 用户信息:性别、年龄、BMI +4. 体质信息:主体质、特征描述 +5. 用药历史:近期用药记录 +6. 产品推荐:体质相关保健品 + +--- + +## 验收标准 + +- [ ] 创建/获取/删除对话正常 +- [ ] 发送消息返回 AI 回复 +- [ ] AI 回复结合用户体质 +- [ ] 对话历史正确保存 +- [ ] 支持 OpenAI 和阿里云切换 +- [ ] 紧急情况提示就医 + +--- + +## 预计耗时 + +40-50 分钟 + +--- + +## 下一步 + +完成后进入 `04-后端开发/07-健康档案模块.md` diff --git a/TODOS/04-后端开发/07-健康档案模块.md b/TODOS/04-后端开发/07-健康档案模块.md new file mode 100644 index 0000000..f734921 --- /dev/null +++ b/TODOS/04-后端开发/07-健康档案模块.md @@ -0,0 +1,96 @@ +# 07-健康档案模块 + +## 目标 + +实现用户健康档案的查询和管理功能,提供完整的健康信息视图。 + +--- + +## 前置要求 + +- 健康调查模块已完成 +- 体质辨识模块已完成 + +--- + +## 实施步骤 + +详细代码请参考原文档 `02-后端开发/07-健康档案模块.md` + +### 主要任务 + +1. 创建用户 Service `internal/service/user.go` + - 获取用户资料 + - 更新用户资料 + - 获取完整健康档案 + +2. 创建用户 Handler `internal/api/handler/user.go` + +3. 更新完整路由配置 + +4. 更新主程序完整版 + +--- + +## API 接口 + +| 方法 | 路径 | 说明 | 认证 | +|-----|------|------|------| +| GET | /api/user/profile | 获取用户资料 | 是 | +| PUT | /api/user/profile | 更新用户资料 | 是 | +| GET | /api/user/health-profile | 获取健康档案 | 是 | +| PUT | /api/user/health-profile | 更新健康档案 | 是 | +| GET | /api/user/lifestyle | 获取生活习惯 | 是 | +| PUT | /api/user/lifestyle | 更新生活习惯 | 是 | + +--- + +## 健康档案数据结构 + +```typescript +interface FullHealthProfile { + basic_info: { + name: string; + gender: string; + height: number; + weight: number; + bmi: number; + blood_type: string; + }; + lifestyle: { + sleep_time: string; + wake_time: string; + exercise_frequency: string; + }; + medical_history: Array<{ disease_name: string; status: string }>; + family_history: Array<{ relation: string; disease_name: string }>; + allergy_records: Array<{ allergen: string; severity: string }>; + constitution: { + primary_type: string; + primary_name: string; + assessed_at: string; + }; +} +``` + +--- + +## 验收标准 + +- [ ] 获取用户资料正常 +- [ ] 获取完整健康档案正常 +- [ ] 更新资料和生活习惯正常 +- [ ] 所有 API 接口可正常调用 +- [ ] 服务器启动日志完整 + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +完成后进入 `04-后端开发/08-保健品商城关联模块.md` diff --git a/TODOS/04-后端开发/08-保健品商城关联模块.md b/TODOS/04-后端开发/08-保健品商城关联模块.md new file mode 100644 index 0000000..76172d9 --- /dev/null +++ b/TODOS/04-后端开发/08-保健品商城关联模块.md @@ -0,0 +1,92 @@ +# 08-保健品商城关联模块 + +## 目标 + +实现保健品数据管理和 AI 问诊时的产品推荐功能,关联外部保健品商城。 + +--- + +## 前置要求 + +- AI 对话模块已完成 +- 体质辨识模块已完成 + +--- + +## 实施步骤 + +详细代码请参考原文档 `02-后端开发/08-保健品商城关联模块.md` + +### 主要任务 + +1. 创建产品数据模型 `internal/model/product.go` + - Product 产品表 + - ConstitutionProduct 体质-产品关联 + - SymptomProduct 症状-产品关联 + +2. 创建种子数据 `internal/database/seed_products.go` + - 36 条模拟产品数据 + - 体质-产品关联 + - 症状-产品关联 + +3. 创建产品 Repository `internal/repository/impl/product.go` + +4. 更新对话 Service,添加产品推荐 + +5. 创建产品 Handler 并更新路由 + +6. 更新主程序初始化产品数据 + +--- + +## API 接口 + +| 方法 | 路径 | 说明 | 认证 | +|-----|------|------|------| +| GET | /api/products | 获取产品列表 | 否 | +| GET | /api/products/:id | 获取产品详情 | 否 | +| GET | /api/products/recommend | 获取推荐产品 | 是 | +| GET | /api/products/search | 搜索产品 | 否 | + +--- + +## 模拟数据统计 + +| 分类 | 数量 | 说明 | +|------|------|------| +| 体质调养类 | 20 | 补气、温阳、滋阴、祛湿、活血、理气、抗敏、综合 | +| 中老年常见类 | 16 | 心脑血管、骨关节、血糖、助眠、健脑、润肠、护眼、免疫 | +| **总计** | **36** | - | + +--- + +## 产品推荐逻辑 + +``` +1. 根据用户体质类型获取推荐产品 +2. 根据对话中的症状关键词匹配产品 +3. 综合推荐,去重后返回 +``` + +--- + +## 验收标准 + +- [ ] 产品列表正常显示 +- [ ] 按分类筛选正常 +- [ ] 根据体质推荐产品正常 +- [ ] 根据症状搜索产品正常 +- [ ] AI 回答中包含产品推荐链接 +- [ ] 种子数据正确初始化 + +--- + +## 预计耗时 + +30-40 分钟 + +--- + +## 下一步 + +后端开发全部完成!进入 `05-前后端对接/01-API服务对接.md` diff --git a/TODOS/05-前后端对接/01-API服务对接.md b/TODOS/05-前后端对接/01-API服务对接.md new file mode 100644 index 0000000..1743657 --- /dev/null +++ b/TODOS/05-前后端对接/01-API服务对接.md @@ -0,0 +1,299 @@ +# 01-API 服务对接 + +## 目标 + +将前端原型从模拟数据切换到真实后端 API。 + +--- + +## 前置要求 + +- APP/Web 原型开发完成 +- 后端 API 服务运行中 + +--- + +## 对接步骤概览 + +### 1. 配置 API 基础地址 + +**APP (React Native):** + +创建 `app/src/config/api.ts`: +```typescript +// 开发环境配置 +export const API_BASE_URL = __DEV__ + ? 'http://localhost:8080/api' + : 'https://api.yourservice.com/api'; + +export const TIMEOUT = 30000; +``` + +**Web (Vue):** + +创建 `web/src/config/api.ts`: +```typescript +export const API_BASE_URL = import.meta.env.DEV + ? 'http://localhost:8080/api' + : 'https://api.yourservice.com/api'; +``` + +--- + +### 2. 创建 HTTP 请求封装 + +**APP (React Native):** + +创建 `app/src/api/client.ts`: +```typescript +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { API_BASE_URL, TIMEOUT } from '../config/api'; + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: any; + headers?: Record; +} + +export async function apiRequest( + endpoint: string, + options: RequestOptions = {} +): Promise { + const token = await AsyncStorage.getItem('token'); + + const headers: Record = { + '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` diff --git a/TODOS/05-前后端对接/02-联调测试.md b/TODOS/05-前后端对接/02-联调测试.md new file mode 100644 index 0000000..9e37536 --- /dev/null +++ b/TODOS/05-前后端对接/02-联调测试.md @@ -0,0 +1,156 @@ +# 02-联调测试 + +## 目标 + +完成前后端联调测试,确保所有功能正常运行。 + +--- + +## 前置要求 + +- API 服务对接完成 +- 后端服务运行中 + +--- + +## 测试清单 + +### 1. 用户认证测试 + +| 测试项 | 操作 | 预期结果 | +|-------|------|---------| +| 注册 | 输入新手机号和密码 | 注册成功,返回 Token | +| 登录 | 输入正确手机号和密码 | 登录成功,跳转首页 | +| 登录失败 | 输入错误密码 | 显示"密码错误"提示 | +| Token 过期 | 使用过期 Token | 自动跳转登录页 | + +### 2. 体质辨识测试 + +| 测试项 | 操作 | 预期结果 | +|-------|------|---------| +| 获取问卷 | 进入体质测试页 | 显示 60+ 题目 | +| 提交问卷 | 完成所有题目并提交 | 显示体质分析结果 | +| 查看结果 | 进入体质结果页 | 显示雷达图和建议 | +| 历史记录 | 查看测评历史 | 显示历史测评列表 | + +### 3. AI 对话测试 + +| 测试项 | 操作 | 预期结果 | +|-------|------|---------| +| 创建对话 | 点击新建对话 | 创建成功 | +| 发送消息 | 输入健康问题 | AI 返回回复 | +| 体质相关 | 询问体质调养 | 回复包含用户体质建议 | +| 产品推荐 | 询问调养产品 | 回复包含产品链接 | +| 紧急情况 | 描述紧急症状 | AI 建议立即就医 | +| 删除对话 | 删除对话 | 对话删除成功 | + +### 4. 用户信息测试 + +| 测试项 | 操作 | 预期结果 | +|-------|------|---------| +| 查看资料 | 进入个人中心 | 显示用户信息 | +| 更新昵称 | 修改昵称 | 更新成功 | +| 健康档案 | 查看健康档案 | 显示完整档案 | + +### 5. 产品推荐测试 + +| 测试项 | 操作 | 预期结果 | +|-------|------|---------| +| 产品列表 | 查看产品列表 | 显示所有产品 | +| 分类筛选 | 选择分类 | 显示对应分类产品 | +| 个性推荐 | 查看推荐产品 | 根据体质推荐 | +| 产品搜索 | 搜索关键词 | 显示匹配产品 | + +--- + +## 测试流程 + +### 完整流程测试 + +``` +1. 新用户注册 + ↓ +2. 完成健康调查 + ↓ +3. 进行体质测试 + ↓ +4. 查看体质结果 + ↓ +5. 开始 AI 对话 + ↓ +6. 获取产品推荐 + ↓ +7. 查看健康档案 + ↓ +8. 退出登录 +``` + +--- + +## 常见问题排查 + +### 网络错误 + +``` +症状: 请求失败,网络错误 +排查: +1. 检查后端服务是否运行: curl http://localhost:8080/health +2. 检查 API 地址配置是否正确 +3. 检查 CORS 配置 +``` + +### 认证错误 + +``` +症状: 401 Unauthorized +排查: +1. 检查 Token 是否正确存储 +2. 检查 Token 是否过期 +3. 检查 Authorization Header 格式 +``` + +### 数据错误 + +``` +症状: 返回数据格式不对 +排查: +1. 检查后端接口返回格式 +2. 检查前端类型定义 +3. 查看控制台 Network 响应 +``` + +--- + +## 性能测试 + +| 测试项 | 目标 | 方法 | +|-------|------|------| +| 首页加载 | < 2s | Chrome DevTools | +| API 响应 | < 500ms | Network 面板 | +| AI 回复 | < 5s | 计时器 | + +--- + +## 验收标准 + +- [ ] 所有功能测试通过 +- [ ] 完整流程无报错 +- [ ] 性能指标达标 +- [ ] 错误处理正常 + +--- + +## 预计耗时 + +60-90 分钟 + +--- + +## 完成 + +恭喜!项目开发完成! + +可选下一步: +- 部署上线 +- 性能优化 +- 功能迭代 diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..d204f9e --- /dev/null +++ b/agents.md @@ -0,0 +1,4 @@ +# Agents 开发规范 + +- 涉及到任何代码修改,记得更新此文档和设计文档 +- diff --git a/app b/app new file mode 160000 index 0000000..c44696e --- /dev/null +++ b/app @@ -0,0 +1 @@ +Subproject commit c44696ea7262b895945c31d230f4db0eaae9109a diff --git a/design.md b/design.md new file mode 100644 index 0000000..d5b6e9e --- /dev/null +++ b/design.md @@ -0,0 +1,1049 @@ +# 健康 AI 问询助手项目设计方案 + +## 一、项目概述 + +健康 AI 问询助手是一个智能健康咨询平台,用户可通过 WEB 端和移动 APP 进行健康相关问题的咨询,AI 将基于医学知识库提供专业解答和健康建议。 + +--- + +## 二、技术栈推荐 + +### 前端技术 + +| 平台 | 技术选型 | 理由 | +| ----------- | ----------------------------------------------- | -------------------------------------------- | +| WEB 端 | **Vue 3 + TypeScript + Vite** | 国内生态好,学习曲线平缓,开发效率高 | +| APP 端 | **React Native + TypeScript** | 跨平台原生体验,性能优秀,生态成熟 | +| Web UI 框架 | **Element Plus** | 成熟稳定,组件丰富 | +| App UI 框架 | **React Native Paper / NativeBase** | Material Design 风格,组件完善 | +| 状态管理 | **Pinia (Web) / Zustand (App)** | 轻量高效,TypeScript 支持好 | +| 导航 | **Vue Router (Web) / React Navigation (App)** | 各平台主流导航方案 | + +### 后端技术 + +| 组件 | 技术选型 | 理由 | +| -------- | ------------------------- | ------------------------------------------ | +| 语言 | **Go 1.21+** | 高性能,并发能力强,部署简单 | +| Web 框架 | **Gin** | 轻量高效,生态成熟,中间件丰富 | +| ORM | **GORM** | 功能完善,支持自动迁移,多数据库兼容 | +| 数据库 | **SQLite(默认)** | 轻量简单,零配置,适合演示和开发 | +| 配置管理 | **Viper** | 支持多种配置格式,环境变量绑定 | +| 日志 | **Zap** | 高性能结构化日志 | +| AI 接口 | **OpenAI API / 通义千问** | 成熟稳定,效果好 | + +### 数据库扩展方案 + +系统采用 **Repository 模式** + **GORM 抽象层**,支持通过配置切换数据库: + +| 数据库 | 适用场景 | 切换方式 | +| ---------- | ---------------- | -------------------- | +| SQLite | 开发/演示/小规模 | 默认,零配置 | +| PostgreSQL | 生产环境推荐 | 修改配置文件即可 | +| MySQL | 已有MySQL环境 | 修改配置文件即可 | + +**配置示例 (config.yaml):** + +```yaml +database: + driver: sqlite # 可选: sqlite, postgres, mysql + # SQLite 配置 + sqlite: + path: ./data/health.db + # PostgreSQL 配置(需要时启用) + postgres: + host: localhost + port: 5432 + user: postgres + password: + dbname: health_app + # MySQL 配置(需要时启用) + mysql: + host: localhost + port: 3306 + user: root + password: + dbname: health_app + +# AI 服务配置 +ai: + provider: aliyun # 可选: openai, aliyun (通义千问) + max_history_messages: 10 # 发送给AI的最大历史消息数 + max_tokens: 2000 # 最大返回 token 数 + + # OpenAI 配置 + openai: + api_key: "" # OpenAI API Key + base_url: "https://api.openai.com/v1" + model: "gpt-3.5-turbo" # 可选: gpt-3.5-turbo, gpt-4 + + # 阿里云通义千问配置 + aliyun: + api_key: "" # 阿里云 DashScope API Key + model: "qwen-turbo" # 可选: qwen-turbo, qwen-plus, qwen-max +``` + +**AI 服务说明:** + +| 服务商 | Provider | 模型选项 | API Key 获取 | +|--------|----------|----------|--------------| +| OpenAI | `openai` | gpt-3.5-turbo, gpt-4 | https://platform.openai.com/api-keys | +| 阿里云通义千问 | `aliyun` | qwen-turbo, qwen-plus, qwen-max | https://dashscope.console.aliyun.com/apiKey | + +**架构设计要点:** +- 使用 GORM 统一 ORM 层,SQL 语法自动适配 +- Repository 接口隔离数据访问逻辑 +- 避免使用特定数据库的专有语法 +- 数据库迁移脚本兼容多种数据库 + +--- + +## 三、系统架构 + +```mermaid +flowchart TB + subgraph client [客户端] + Web[Web端_Vue3] + App[App端_ReactNative] + end + + subgraph backend [后端服务_Go_Gin] + API[Gin服务] + Auth[认证模块] + Survey[健康调查模块] + Constitution[体质辨识模块] + AI[AI对话模块] + Health[健康档案模块] + end + + subgraph storage [数据存储] + DB[(SQLite)] + end + + subgraph external [外部服务] + LLM[AI大模型API] + end + + Web --> API + App --> API + API --> Auth + API --> Survey + API --> Constitution + API --> AI + API --> Health + Auth --> DB + Survey --> DB + Constitution --> DB + AI --> LLM + AI -.->|获取体质信息| Constitution + Health --> DB +``` + +### 新用户完整流程 + +```mermaid +flowchart LR + A[用户注册] --> B[基础信息填写] + B --> C[生活习惯调查] + C --> D[健康状况调查] + D --> E[体质问卷测评] + E --> F[体质结果展示] + F --> G[AI问诊功能] + + G -->|定期提醒| H[体质重新测评] + H --> F +``` + +--- + +## 四、功能模块设计 + +### 4.1 用户模块 + +- 用户注册/登录(手机号/邮箱) +- 个人资料管理(基本信息、健康档案) +- 第三方登录(微信) +- JWT Token 认证 + +### 4.2 新用户健康调查(核心功能) + +新用户注册后必须完成健康调查问卷,系统根据问卷结果判断用户体质类型。 + +#### 4.2.1 基础信息采集 + +- **个人基本信息** + - 姓名、性别、出生日期 + - 身高、体重(自动计算 BMI) + - 职业、婚姻状况 + - 所在地区 +- **生活习惯调查** + - 作息时间(入睡时间、起床时间、睡眠质量) + - 饮食习惯(三餐规律、饮食偏好、饮水量) + - 运动习惯(运动频率、运动类型、运动时长) + - 烟酒情况(是否吸烟、饮酒频率) +- **健康状况调查** + - 既往病史(慢性病、手术史) + - 家族病史(糖尿病、高血压、心脏病、癌症等) + - 过敏史(药物过敏、食物过敏、其他过敏) + - 当前用药情况 + +#### 4.2.2 中医体质辨识问卷 + +基于《中医体质分类与判定》标准,包含 60+道问题,涵盖九种体质: + +| 体质类型 | 特征描述 | 常见表现 | +| -------- | ------------ | ------------------------------ | +| 平和质 | 阴阳气血调和 | 体态适中,面色红润,精力充沛 | +| 气虚质 | 元气不足 | 易疲劳,气短懒言,易出汗 | +| 阳虚质 | 阳气不足 | 畏寒怕冷,手脚冰凉,喜热饮 | +| 阴虚质 | 阴液亏少 | 口燥咽干,手足心热,盗汗 | +| 痰湿质 | 痰湿凝聚 | 形体肥胖,腹部肥满,痰多 | +| 湿热质 | 湿热内蕴 | 面垢油光,口苦口干,大便黏滞 | +| 血瘀质 | 血行不畅 | 肤色晦暗,易生斑点,健忘 | +| 气郁质 | 气机郁滞 | 情绪低落,多愁善感,胸闷 | +| 特禀质 | 先天失常 | 过敏体质,易打喷嚏,皮肤易过敏 | + +#### 4.2.3 体质判定算法 + +``` +原始分 = 各条目得分之和 +转化分 = (原始分 - 条目数) / (条目数 × 4) × 100 + +判定标准: +- 平和质:转化分 ≥ 60分,其他8种体质转化分均 < 30分 +- 偏颇体质:转化分 ≥ 40分为"是",30-39分为"倾向是" +- 可存在多种偏颇体质(复合体质) +``` + +#### 4.2.4 调查结果展示 + +- 体质雷达图(九种体质得分可视化) +- 主要体质类型及特征说明 +- 个性化健康建议 + - 饮食调养建议 + - 起居调养建议 + - 运动调养建议 + - 情志调养建议 + - 易发疾病风险提示 + +### 4.3 AI 问诊模块 + +- 多轮对话支持 +- **结合用户体质进行个性化回答** +- 对话历史记录 +- 智能追问(症状细化) +- 健康建议生成(基于体质类型) +- 免责声明展示 + +#### 4.3.1 AI Agent 系统提示词 + +``` +# 角色定义 +你是"健康AI助手",一个专业的健康咨询助理。你基于中医体质辨识理论,为用户提供个性化的健康建议。 + +## 重要声明 +- 你不是专业医师,仅提供健康咨询和养生建议 +- 你的建议不能替代医生的诊断和治疗 +- 遇到以下情况,必须立即建议用户就医: + * 胸痛、呼吸困难、剧烈头痛 + * 高烧不退(超过39°C持续24小时) + * 意识模糊、晕厥 + * 严重外伤、大量出血 + * 持续剧烈腹痛 + * 疑似中风症状(口眼歪斜、肢体无力、言语不清) + * 任何可能危及生命的紧急状况 + +## 用户信息 +{{user_profile}} + +## 用户体质 +{{constitution_info}} + +## 用药历史 +{{medication_history}} + +## 已购买保健品 +{{purchase_history}} + +## 回答原则 + +### 1. 简洁原则 +- 回答控制在200字以内 +- 使用简短句子,避免冗长解释 +- 重点突出,条理清晰 + +### 2. 体质结合原则 +根据用户体质特点给出针对性建议: +- 气虚质:建议补气食物,避免过度劳累 +- 阳虚质:建议温补,避免寒凉 +- 阴虚质:建议滋阴,避免辛辣燥热 +- 痰湿质:建议祛湿,控制饮食 +- 湿热质:建议清热利湿,饮食清淡 +- 血瘀质:建议活血化瘀,适当运动 +- 气郁质:建议疏肝理气,调节情绪 +- 特禀质:注意过敏原,增强体质 +- 平和质:维持现状,均衡养生 + +### 3. 用药建议原则 +- 优先推荐非处方中成药或食疗方案 +- 结合用户用药历史,避免重复或冲突 +- 注明"建议咨询药师后使用" +- 有过敏史时特别提醒 + +### 4. 安全原则 +- 不推荐处方药 +- 不做疾病诊断 +- 症状严重时直接建议就医 +- 涉及儿童、孕妇、老人时更加谨慎 + +### 5. 产品推荐原则 +当回答涉及调养建议时,根据以下规则推荐保健品: +- 根据用户体质匹配相关产品 +- 根据症状关键词匹配产品 +- 最多推荐2个产品 +- 保健品不能替代药物治疗,仅作日常调养参考 +- 必须包含商城链接 + +## 可推荐产品 +{{product_list}} + +## 回答格式 + +【情况分析】一句话概括 +【建议】 +1. 具体建议1 +2. 具体建议2 +【用药参考】(如适用) +- 药品名称:用法用量(建议咨询药师) +【推荐调养产品】(根据体质/症状匹配,非必须) +- 产品名称 ¥价格 [点击购买](商城链接) +【提醒】注意事项或就医建议 +``` + +#### 4.3.2 动态变量说明 + +| 变量 | 说明 | 数据来源 | +|------|------|----------| +| `{{user_profile}}` | 用户基本信息(性别、年龄、BMI) | HealthProfile 表 | +| `{{constitution_info}}` | 体质类型和特征描述 | ConstitutionAssessment 表 | +| `{{medication_history}}` | 用药历史记录 | MedicalHistory 表 | +| `{{purchase_history}}` | 保健品购买历史(商城同步) | PurchaseHistory 表 | +| `{{product_list}}` | 可推荐产品列表(按体质筛选) | Product 表 | + +#### 4.3.3 对话历史管理 + +- 每次发送给 AI 的历史消息数量限制为 `max_history_messages`(默认10条) +- 超出限制时,保留最近的消息 +- 用户消息和 AI 回复均持久化存储到数据库 + +### 4.4 健康档案模块 + +- 基础健康信息(年龄、性别、身高、体重、血型、BMI) +- **体质辨识结果**(支持重新测评) +- **体质变化追踪**(定期重测对比) +- 既往病史记录 +- 过敏史记录 +- 用药记录 +- 体检报告上传 + +### 4.5 系统功能 + +- 消息通知 +- **体质重测提醒**(建议每 3-6 个月重测) +- 反馈建议 +- 使用帮助 +- 隐私政策 + +--- + +## 五、数据模型设计 + +```mermaid +erDiagram + User ||--o{ Conversation : has + User ||--o| HealthProfile : has + User ||--o{ ConstitutionAssessment : takes + User ||--o{ PurchaseHistory : has + Conversation ||--|{ Message : contains + HealthProfile ||--o{ MedicalHistory : has + HealthProfile ||--o{ AllergyRecord : has + HealthProfile ||--o{ FamilyHistory : has + ConstitutionAssessment ||--|{ AssessmentAnswer : contains + + User { + int id PK + string phone + string email + string password_hash + string nickname + string avatar + boolean survey_completed + datetime created_at + } + + HealthProfile { + int id PK + int user_id FK + string name + date birth_date + string gender + float height + float weight + float bmi + string blood_type + string occupation + string marital_status + string region + } + + LifestyleInfo { + int id PK + int user_id FK + time sleep_time + time wake_time + string sleep_quality + string meal_regularity + string diet_preference + int daily_water_ml + string exercise_frequency + string exercise_type + int exercise_duration_min + boolean is_smoker + string alcohol_frequency + } + + ConstitutionAssessment { + int id PK + int user_id FK + datetime assessed_at + json scores + string primary_constitution + json secondary_constitutions + json recommendations + } + + AssessmentAnswer { + int id PK + int assessment_id FK + int question_id + int score + } + + MedicalHistory { + int id PK + int health_profile_id FK + string disease_name + string disease_type + date diagnosed_date + string status + text notes + } + + FamilyHistory { + int id PK + int health_profile_id FK + string relation + string disease_name + text notes + } + + AllergyRecord { + int id PK + int health_profile_id FK + string allergy_type + string allergen + string severity + text reaction_desc + } + + Conversation { + int id PK + int user_id FK + string title + datetime created_at + datetime updated_at + } + + Message { + int id PK + int conversation_id FK + string role + text content + datetime created_at + } + + PurchaseHistory { + int id PK + int user_id FK + string order_no + int product_id + string product_name + datetime purchased_at + string source + } +``` + +### 购买历史表(PurchaseHistory) + +> 由保健品商城同步,用于AI问诊时参考用户已购买的保健品 + +| 字段 | 类型 | 说明 | +| ------------ | ----------- | ----------------------- | +| id | int | 记录 ID | +| user_id | int | 用户 ID | +| order_no | string(50) | 商城订单号 | +| product_id | int | 产品 ID | +| product_name | string(100) | 产品名称 | +| purchased_at | datetime | 购买时间 | +| source | string(20) | 来源(mall=保健品商城) | + +### 体质问卷题目表(QuestionBank) + +| 字段 | 类型 | 说明 | +| ----------------- | ------ | --------------------------------------------- | +| id | int | 题目 ID | +| constitution_type | string | 所属体质类型 | +| question_text | string | 题目内容 | +| options | json | 选项(从不/很少/有时/经常/总是,对应 1-5 分) | +| order_num | int | 显示顺序 | + +### 保健品商城关联表 + +> 用于 AI 问诊时推荐相关保健品,链接到外部商城系统 + +**保健品表(Product)** + +| 字段 | 类型 | 说明 | +| ----------- | ------------- | ---------------------------- | +| id | int | 产品 ID | +| name | string(100) | 产品名称 | +| category | string(50) | 分类 | +| description | text | 产品描述 | +| efficacy | text | 功效说明 | +| suitable | text | 适用人群/体质 | +| price | decimal(10,2) | 价格 | +| image_url | string(255) | 产品图片 | +| mall_url | string(255) | 商城链接 | +| is_active | boolean | 是否上架 | + +**体质-产品关联表(ConstitutionProduct)** + +| 字段 | 类型 | 说明 | +| ----------------- | ---------- | ------------ | +| id | int | 关联 ID | +| constitution_type | string(20) | 体质类型 | +| product_id | int | 产品 ID | +| priority | int | 推荐优先级 | +| reason | string(200)| 推荐理由 | + +**症状-产品关联表(SymptomProduct)** + +| 字段 | 类型 | 说明 | +| --------- | ---------- | ---------------- | +| id | int | 关联 ID | +| keyword | string(50) | 症状关键词 | +| product_id| int | 产品 ID | +| priority | int | 推荐优先级 | + +**产品分类说明** + +| 分类 | 适用体质/问题 | 代表产品 | +|------|---------------|----------| +| 补气类 | 气虚质 | 黄芪精、人参蜂王浆、西洋参 | +| 温阳类 | 阳虚质 | 鹿茸胶囊、桂圆红枣茶 | +| 滋阴类 | 阴虚质 | 枸杞原浆、燕窝、石斛 | +| 祛湿类 | 痰湿质、湿热质 | 红豆薏米粉、茯苓糕、祛湿茶 | +| 活血类 | 血瘀质 | 三七粉、丹参片 | +| 理气类 | 气郁质 | 玫瑰花茶、陈皮普洱茶 | +| 抗敏类 | 特禀质 | 益生菌、蜂胶 | +| 心脑血管类 | 高血压、高血脂 | 深海鱼油、纳豆激酶、卵磷脂 | +| 骨关节类 | 骨质疏松、关节痛 | 氨糖软骨素、钙片、骨胶原 | +| 血糖调节类 | 血糖偏高 | 苦瓜素、桑叶茶 | +| 助眠安神类 | 失眠 | 褪黑素、酸枣仁膏 | +| 健脑益智类 | 记忆力减退 | 银杏叶片、DHA | +| 润肠通便类 | 便秘 | 膳食纤维、酵素 | +| 护眼明目类 | 视力下降 | 叶黄素、蓝莓提取物 | +| 增强免疫类 | 免疫力低 | 灵芝孢子粉、蛋白粉 | + +--- + +## 六、API 接口设计 + +### 认证接口 + +- `POST /api/auth/register` - 用户注册 +- `POST /api/auth/login` - 用户登录 +- `POST /api/auth/refresh` - 刷新 Token + +### 用户接口 + +- `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/constitution/recommendations` - 获取体质调养建议 + +### 对话接口 + +- `GET /api/conversations` - 获取对话列表 +- `POST /api/conversations` - 创建新对话 +- `GET /api/conversations/{id}` - 获取对话详情 +- `DELETE /api/conversations/{id}` - 删除对话 +- `POST /api/conversations/{id}/messages` - 发送消息(流式响应,AI 会结合体质信息回答) + +### 产品接口(保健品商城关联) + +- `GET /api/products` - 获取产品列表(支持分类筛选) +- `GET /api/products/{id}` - 获取产品详情 +- `GET /api/products/recommend` - 根据用户体质获取推荐产品 +- `GET /api/products/search?keyword=xxx` - 根据症状关键词搜索产品 + +### 商城数据同步接口 + +- `POST /api/sync/purchase` - 接收商城购买记录(用于AI问诊参考) + +--- + +## 七、与保健品商城的集成 + +### 7.1 接收商城跳转 + +商城用户点击"AI咨询"按钮后跳转到本系统,需处理以下场景: + +| 跳转参数 | 处理逻辑 | +|----------|----------| +| `source=mall` | 标识来源为商城 | +| `product_id` | 自动创建对话并询问该产品是否适合用户 | +| `product_name` | 产品名称,用于生成问题 | + +**前端处理逻辑:** + +```typescript +// Web: views/Chat.vue +// APP: screens/Chat.tsx + +onMounted(() => { + const query = route.query + + if (query.source === 'mall' && query.product_id) { + // 从商城跳转,自动发起产品咨询 + const productName = query.product_name || '该产品' + const autoMessage = `我想了解【${productName}】这款保健品是否适合我的体质?` + + // 自动发送消息 + handleSendMessage(autoMessage) + } +}) +``` + +### 7.2 跳转到商城 + +在AI对话回答和体质结果页提供商城跳转入口: + +```typescript +// utils/mall.ts +const MALL_BASE_URL = import.meta.env.VITE_MALL_URL || 'http://localhost:5174' + +// 跳转到商城产品详情 +export function jumpToMallProduct(productId: number) { + window.location.href = `${MALL_BASE_URL}/product/${productId}?source=health-ai` +} + +// 跳转到商城体质推荐列表 +export function jumpToMallRecommend(constitutionType: string) { + window.location.href = `${MALL_BASE_URL}/recommend?constitution=${constitutionType}&source=health-ai` +} +``` + +### 7.3 购买记录同步 + +接收商城同步的购买记录,用于AI问诊时参考用户已购买的保健品: + +```go +// POST /api/sync/purchase +type PurchaseSyncRequest struct { + UserID uint `json:"user_id"` + OrderNo string `json:"order_no"` + Products []struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"products"` + CreatedAt time.Time `json:"created_at"` +} + +func HandlePurchaseSync(c *gin.Context) { + // 验证同步密钥 + // 存储到用户购买历史表 + // AI问诊时可获取:"用户近期购买了:氨糖软骨素、深海鱼油" +} +``` + +--- + +## 八、项目目录结构 + +``` +healthApps/ +├── web/ # Web前端 (Vue 3) +│ ├── src/ +│ │ ├── api/ # API请求 +│ │ ├── components/ # 公共组件 +│ │ ├── views/ # 页面 +│ │ ├── stores/ # Pinia状态管理 +│ │ ├── router/ # 路由配置 +│ │ └── utils/ # 工具函数 +│ └── package.json +│ +├── app/ # 移动端 (React Native) +│ ├── src/ +│ │ ├── api/ # API请求 +│ │ ├── components/ # 公共组件 +│ │ ├── screens/ # 页面/屏幕 +│ │ ├── navigation/ # 导航配置 +│ │ ├── stores/ # Zustand状态管理 +│ │ ├── hooks/ # 自定义Hooks +│ │ └── utils/ # 工具函数 +│ ├── android/ # Android原生代码 +│ ├── ios/ # iOS原生代码 +│ ├── app.json +│ └── package.json +│ +├── server/ # 后端服务 (Go + Gin) +│ ├── cmd/ +│ │ └── server/ +│ │ └── main.go # 程序入口 +│ ├── internal/ +│ │ ├── api/ # API路由和Handler +│ │ │ ├── handler/ # 请求处理器 +│ │ │ ├── middleware/ # 中间件 +│ │ │ └── router.go # 路由配置 +│ │ ├── model/ # 数据模型(GORM兼容) +│ │ ├── service/ # 业务逻辑 +│ │ ├── repository/ # 数据访问层(接口+实现) +│ │ │ ├── interface.go # Repository接口定义 +│ │ │ └── impl/ # 具体实现 +│ │ ├── config/ # 配置管理 +│ │ └── database/ # 数据库初始化(多驱动支持) +│ ├── pkg/ # 公共包 +│ │ ├── jwt/ # JWT工具 +│ │ ├── response/ # 统一响应 +│ │ └── utils/ # 工具函数 +│ ├── data/ # 数据目录 +│ │ └── health.db # SQLite数据库文件 +│ ├── config.yaml # 配置文件(数据库可切换) +│ ├── go.mod +│ └── go.sum +│ +├── docs/ # 文档 +└── design.md # 设计文档 +``` + +--- + +## 九、安全与合规 + +1. **数据安全**:所有敏感数据加密存储,传输使用 HTTPS +2. **隐私保护**:符合个人信息保护法要求,用户数据不对外泄露 +3. **医疗免责**:明确 AI 仅提供健康咨询建议,不构成医疗诊断 +4. **访问控制**:基于 JWT 的身份认证,接口权限控制 + +--- + +## 十、UI 设计规范 + +> 参考设计稿位于 `files/ui/` 目录 + +### 9.1 全局设计规范 + +#### 配色方案 + +| 类型 | 色值 | 用途 | +|------|------|------| +| 主色调 | `#10B981` | 导航高亮、主按钮、强调元素 | +| 主色渐变 | `#10B981 → #2EC4B6` | 顶部背景、卡片装饰 | +| 辅助紫 | `#8B5CF6` | AI咨询图标、起居图标 | +| 辅助青 | `#14B8A6` | 体质自测图标 | +| 辅助橙 | `#F97316` | 用药情况图标 | +| 辅助粉 | `#EC4899` | 情志图标 | +| 页面背景 | `#F5F5F5` | 整体页面背景 | +| 卡片背景 | `#FFFFFF` | 内容卡片 | +| 主文字 | `#1F2937` | 标题、正文 | +| 次文字 | `#6B7280` | 描述、说明 | +| 提示文字 | `#9CA3AF` | 占位符、时间 | + +#### 圆角规范 + +| 元素 | 圆角值 | +|------|--------| +| 大卡片 | 16px | +| 中卡片 | 12px | +| 按钮(全宽) | 24px | +| 小按钮/标签 | 8px | +| 头像 | 50% (圆形) | +| 输入框 | 24px | + +#### 阴影规范 + +```css +/* 卡片阴影 */ +box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + +/* 悬浮阴影 */ +box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +``` + +#### 间距规范 + +| 类型 | 值 | +|------|-----| +| 页面边距 | 16px | +| 卡片内边距 | 16px | +| 元素间距(小) | 8px | +| 元素间距(中) | 12px | +| 元素间距(大) | 16px | +| 区块间距 | 24px | + +### 9.2 底部导航栏 + +**Tab 项目:** 首页、AI问答、体质分析、我的 + +| 状态 | 图标颜色 | 文字颜色 | +|------|----------|----------| +| 选中 | `#10B981` | `#10B981` | +| 未选中 | `#9CA3AF` | `#9CA3AF` | + +**图标:** +- 首页:房屋图标 +- AI问答:对话气泡图标 +- 体质分析:心电图/波浪图标 +- 我的:用户图标 + +### 9.3 页面设计规范 + +#### 9.3.1 登录页 + +| 区域 | 设计要点 | +|------|----------| +| 整体 | **简洁白色背景**,现代扁平风格 | +| 顶部 | Logo 图标(浅蓝背景圆角框)+ "健康AI助手" + slogan | +| 表单 | "欢迎登录" 标题 + 手机号/验证码输入框(圆角12px) | +| 按钮 | **蓝色 `#3B82F6`** 全宽圆角按钮 | +| 底部 | 用户协议和隐私政策链接 | + +**更新说明(v1.1):** 登录页改为简洁现代的白色风格,主按钮使用蓝色,整体更加清爽简约。 + +#### 9.3.2 首页 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 Header | 绿色背景 + 动态问候语 + 祝福语 | +| 体质卡片 | 白色圆角卡片(负margin悬浮),体质图标 + 体质名称 + 描述 | +| 快速功能 | **4宫格布局**:AI问诊、体质测试、健康档案、用药记录 | +| 健康提示 | 黄色背景卡片,今日健康小贴士 | +| **健康资讯** | 健康知识科普信息列表卡片(新增) | + +**快速功能卡片:** + +| 功能 | 图标颜色 | 描述 | +|------|----------|------| +| AI问诊 | 蓝色 `#3B82F6` | 24小时智能健康问答 | +| 体质测试 | 绿色 `#10B981` | 科学分析您的体质类型 | +| 健康档案 | 紫色 `#8B5CF6` | 查看个人健康记录 | +| 用药记录 | 橙色 `#F59E0B` | 管理用药信息 | + +**更新说明(v1.1):** +- 首页移除"推荐调养产品"区块(移至体质结果页) +- 新增"健康资讯"卡片列表,展示健康知识科普信息 + +#### 9.3.3 体质分析首页 + +| 区域 | 设计要点 | +|------|----------| +| 顶部卡片 | 绿色渐变背景,"中医体质自测"介绍文案 | +| 测试说明 | 白色卡片,3步骤说明(带序号圆圈) | +| 操作按钮 | 绿色全宽圆角按钮"开始测试" | + +**步骤说明:** +1. 回答65个问题 - 根据您的真实情况选择最符合的答案 +2. 获取分析报告 - 系统将为您分析体质类型并提供建议 +3. 个性化建议 - 根据结果提供针对性的健康建议 + +#### 9.3.4 体质问卷页 + +| 区域 | 设计要点 | +|------|----------| +| 导航栏 | 返回箭头 + "中医体质辨识自测" + 进度显示"1/65" | +| 进度条 | 绿色细条,显示答题进度 | +| 问题卡片 | 绿色"问题N"标签 + 问题文字 | +| 选项区 | 5个白色圆角按钮,纵向排列 | +| 底部提示 | 浅绿色背景提示框 | + +**选项文字:** 从不、很少、有时、经常、总是 + +#### 9.3.5 体质结果页 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 绿色背景 + 人体轮廓图 + 分享按钮 | +| 结果展示 | 主体质名称(大字)+ 分数徽章 + 状态描述 | +| 雷达图卡片 | 九种体质得分雷达图可视化 | +| 体质特征 | 图标 + 特征描述文字 | +| 调理建议 | 2×2 网格布局(起居、饮食、运动、情志) | +| **推荐调养产品** | 根据体质推荐的保健品列表(新增) | + +**调理建议卡片配色:** + +| 类型 | 图标背景色 | +|------|------------| +| 起居 | 紫色 `#EDE9FE` | +| 饮食 | 青色 `#CCFBF1` | +| 运动 | 紫色 `#EDE9FE` | +| 情志 | 粉色 `#FCE7F3` | + +**更新说明(v1.1):** 推荐调养产品从首页移至体质结果页,更符合使用场景。 + +#### 9.3.6 AI问答首页 + +| 区域 | 设计要点 | +|------|----------| +| 导航栏 | "AI问答" + **右侧"历史记录"按钮** | +| 空状态 | 对话图标 + 提示文案 + "开始对话"按钮 | +| 新建按钮 | 右下角悬浮FAB按钮 | +| **历史管理弹窗** | Modal展示历史对话列表,支持编辑/删除 | + +**更新说明(v1.1):** +- 进入AI问答时,如有历史对话自动进入最近一次 +- 标题右侧添加"历史记录"入口 +- 历史弹窗支持编辑模式,可删除历史对话 +- 支持新建对话 + +**快捷问题示例:** +- 我最近总是感觉疲劳怎么办? +- 如何改善睡眠质量? +- 感冒了应该注意什么? + +#### 9.3.7 AI对话详情页 + +| 元素 | 设计要点 | +|------|----------| +| AI消息 | 左对齐,机器人图标(蓝色背景),灰色/白色气泡 | +| 用户消息 | 右对齐,用户图标(绿色背景),绿色气泡 | +| 时间显示 | 消息下方,灰色小字"08:45" | +| 输入区 | 固定底部,麦克风 + 输入框 + 发送按钮 | + +#### 9.3.8 我的页面 + +| 区域 | 设计要点 | +|------|----------| +| 顶部 | 白色背景 + "我的"标题 | +| 用户卡片 | 头像 + 姓名 + 手机号 + 体质标签 + 编辑按钮 | +| **适老模式卡片** | 黄色背景卡片,开关控制(新增) | +| 健康管理 | 健康档案、**用药记录**、体质报告、对话历史 | +| 其他 | 健康商城、关于我们 | + +**列表项样式:** +- 左侧:圆形彩色图标背景 +- 中间:标题 + 描述文字 +- 右侧:右箭头或数量角标 + +**更新说明(v1.1):** +- 新增"适老模式"开关卡片,放大字体和组件,方便中老年用户使用 +- 新增"用药记录"管理功能,支持查看和添加用药记录 +- 用药记录弹窗显示药品名称、用量、频次、日期等信息 + +### 9.4 组件设计规范 + +#### 按钮 + +| 类型 | 样式 | +|------|------| +| 主按钮 | 背景 `#10B981`,文字白色,圆角 24px | +| 次按钮 | 背景白色,边框 `#10B981`,文字 `#10B981` | +| 文字按钮 | 无背景,文字 `#10B981` | +| 禁用状态 | 背景 `#D1D5DB`,文字 `#9CA3AF` | + +#### 输入框 + +| 状态 | 样式 | +|------|------| +| 默认 | 背景 `#F3F4F6`,圆角 24px,无边框 | +| 聚焦 | 背景白色,边框 `#10B981` | +| 错误 | 边框 `#EF4444`,提示文字红色 | + +#### 卡片 + +| 类型 | 样式 | +|------|------| +| 功能卡片 | 白色背景,16px 圆角,8px 阴影 | +| 信息卡片 | 白色背景,12px 圆角,内边距 16px | +| 彩色卡片 | 渐变背景,16px 圆角 | + +#### 图标 + +| 场景 | 规范 | +|------|------| +| 功能入口 | 48px 圆形背景 + 24px 图标 | +| Tab导航 | 24px 图标 | +| 列表项 | 40px 圆形背景 + 20px 图标 | +| 操作按钮 | 24px 图标 | + +### 9.5 适老模式设计规范(v1.1 新增) + +为方便中老年用户使用,系统提供"适老模式"可一键切换。 + +| 元素 | 普通模式 | 适老模式 | +|------|----------|----------| +| 基础字号 | 14px | 17.5px (×1.25) | +| 标题字号 | 18px | 22.5px (×1.25) | +| 间距 | 12px | 14.4px (×1.2) | +| 头像大小 | 64px | 80px | +| 按钮高度 | 48px | 56px | +| 图标大小 | 24px | 30px | + +**实现方式:** +- 使用全局状态管理(Zustand)存储适老模式开关 +- 通过 `getFontSize(elderMode, baseSize)` 函数动态计算字号 +- 设置存储于 AsyncStorage,用户重启后保持设置 + +--- + +## 十、功能更新记录 + +### v1.1 更新内容 + +| 功能 | 更新内容 | +|------|----------| +| 登录页 | 改为简洁现代的白色风格,主按钮使用蓝色 | +| 首页 | 移除推荐产品,新增健康资讯卡片 | +| 体质结果页 | 新增推荐调养产品区块 | +| AI问答 | 进入时自动恢复历史对话,标题右侧添加历史管理入口,支持删除对话 | +| 我的页面 | 新增用药记录管理、适老模式开关 | +| 适老模式 | 全局字体和组件放大25%,方便中老年用户 | + +--- + +## 十一、实施步骤 + +1. **环境搭建** - 创建项目结构,配置开发环境 +2. **后端开发** - 实现 API 接口、数据库模型、AI 对接 +3. **Web 前端** - 实现用户界面和交互逻辑 +4. **App 开发** - 基于 uni-app 开发移动端 +5. **测试优化** - 功能测试、性能优化 +6. **部署上线** - 服务器部署、域名配置 diff --git a/files/ui/体质分析.png b/files/ui/体质分析.png new file mode 100644 index 0000000..b4ac572 Binary files /dev/null and b/files/ui/体质分析.png differ diff --git a/files/ui/体质检测.png b/files/ui/体质检测.png new file mode 100644 index 0000000..e9fb512 Binary files /dev/null and b/files/ui/体质检测.png differ diff --git a/files/ui/体质页.png b/files/ui/体质页.png new file mode 100644 index 0000000..fcb3175 Binary files /dev/null and b/files/ui/体质页.png differ diff --git a/files/ui/我的.png b/files/ui/我的.png new file mode 100644 index 0000000..b637f31 Binary files /dev/null and b/files/ui/我的.png differ diff --git a/files/ui/登录页.png b/files/ui/登录页.png new file mode 100644 index 0000000..f541a61 Binary files /dev/null and b/files/ui/登录页.png differ diff --git a/files/ui/问答对话.png b/files/ui/问答对话.png new file mode 100644 index 0000000..ca4ccbb Binary files /dev/null and b/files/ui/问答对话.png differ diff --git a/files/ui/问答页.png b/files/ui/问答页.png new file mode 100644 index 0000000..9d2f874 Binary files /dev/null and b/files/ui/问答页.png differ diff --git a/files/ui/首页.png b/files/ui/首页.png new file mode 100644 index 0000000..8bdc518 Binary files /dev/null and b/files/ui/首页.png differ diff --git a/mall-design.md b/mall-design.md new file mode 100644 index 0000000..6d36237 --- /dev/null +++ b/mall-design.md @@ -0,0 +1,878 @@ +# 保健品商城 - 总体设计方案 + +> 关联项目:健康AI问询助手 +> 开发顺序:在健康AI问询助手项目完成后开发 + +--- + +## 一、项目概述 + +### 1.1 项目背景 + +本项目是"健康AI问询助手"的关联项目,为用户提供保健品购买服务。通过AI问诊系统的体质分析和健康建议,智能推荐适合用户的保健品,实现"健康咨询 → 产品推荐 → 购买转化"的完整闭环。 + +### 1.2 核心价值 + +- **精准推荐**:基于用户体质和健康状况,提供个性化产品推荐 +- **信任背书**:AI健康助手的专业分析增强用户购买信心 +- **便捷体验**:从健康咨询到产品购买的无缝衔接 +- **用户粘性**:健康管理与产品消费的双向绑定 + +### 1.3 目标用户 + +| 用户群体 | 特点 | 需求 | +|----------|------|------| +| 中老年人 | 50-70岁,注重养生 | 心脑血管、骨关节、助眠、血糖调节 | +| 亚健康白领 | 25-45岁,工作压力大 | 补气、抗疲劳、护眼、免疫力 | +| 养生爱好者 | 各年龄段,关注中医体质 | 体质调养、食疗产品 | + +--- + +## 二、技术架构 + +### 2.1 技术栈 + +| 层次 | 技术选型 | 说明 | +|------|----------|------| +| Web前端 | Vue 3 + TypeScript + Vite | 与健康AI项目保持一致 | +| APP端 | React Native + TypeScript | 与健康AI项目保持一致 | +| 后端服务 | Go + Gin + GORM | 与健康AI项目保持一致 | +| 数据库 | SQLite / PostgreSQL | 支持切换,与健康AI共享用户表 | +| 支付对接 | 微信支付 / 支付宝 | 预留接口 | +| 对象存储 | 阿里云OSS / 本地存储 | 产品图片存储 | + +### 2.2 系统架构图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 用户端 │ +├─────────────────────┬───────────────────────────────────────────┤ +│ 健康AI Web/APP │ 商城 Web/APP │ +│ (体质分析/AI问诊) │ (产品浏览/购物车/订单/支付) │ +└─────────┬───────────┴────────────────┬──────────────────────────┘ + │ │ + │ API Gateway │ + │ │ +┌─────────▼────────────────────────────▼──────────────────────────┐ +│ 后端服务层 │ +├─────────────────────┬───────────────────────────────────────────┤ +│ 健康AI服务 │ 商城服务 │ +│ - 用户认证 │ - 产品管理 │ +│ - 体质辨识 │ - 购物车 │ +│ - AI对话 │ - 订单管理 │ +│ - 健康档案 │ - 支付对接 │ +│ - 产品推荐接口 │ - 物流查询 │ +└─────────┬───────────┴────────────────┬──────────────────────────┘ + │ │ +┌─────────▼────────────────────────────▼──────────────────────────┐ +│ 数据层 │ +├─────────────────────┬───────────────────────────────────────────┤ +│ 共享数据 │ 商城独立数据 │ +│ - 用户表 │ - 产品详情表 │ +│ - 产品基础表 │ - SKU表 │ +│ - 体质-产品关联 │ - 购物车表 │ +│ │ - 订单表 │ +│ │ - 支付记录表 │ +└─────────────────────┴───────────────────────────────────────────┘ +``` + +### 2.3 与健康AI项目的双向对接 + +| 对接方式 | 方向 | 说明 | +|----------|------|------| +| 用户体系共享 | 双向 | 使用同一套用户认证(JWT Token互认) | +| 产品数据同步 | 双向 | 健康AI存储基础产品信息,商城扩展详细信息 | +| 产品推荐跳转 | 健康AI → 商城 | AI推荐的产品链接直接跳转到商城产品页 | +| AI咨询跳转 | 商城 → 健康AI | 商城产品页/首页提供AI咨询入口,跳转到健康AI | +| 体质信息获取 | 商城 ← 健康AI | 商城调用健康AI接口获取用户体质,优化推荐排序 | +| 购买记录同步 | 商城 → 健康AI | 用户购买保健品后,同步到健康AI用于AI问诊参考 | + +--- + +## 三、功能模块 + +### 3.1 功能架构 + +``` +保健品商城 +├── 首页模块 +│ ├── Banner轮播 +│ ├── AI健康咨询入口(悬浮按钮) +│ ├── 体质推荐卡片(对接健康AI) +│ ├── 分类导航 +│ ├── 热销推荐 +│ └── 新品上架 +│ +├── 产品模块 +│ ├── 产品列表(分类筛选、排序) +│ ├── 产品详情 +│ │ ├── 图文规格评价 +│ │ ├── "咨询适合我吗"按钮 → 跳转AI问诊 +│ │ └── 相关体质说明 +│ ├── 产品搜索 +│ └── 收藏功能 +│ +├── AI咨询模块(跳转健康AI) +│ ├── 智能健康咨询入口 +│ ├── 体质测试入口 +│ ├── 历史咨询记录 +│ └── 携带产品信息跳转 +│ +├── 购物车模块 +│ ├── 添加/删除商品 +│ ├── 修改数量 +│ ├── 选择结算 +│ └── 失效商品处理 +│ +├── 订单模块 +│ ├── 确认订单 +│ ├── 地址管理 +│ ├── 订单列表 +│ ├── 订单详情 +│ ├── 取消订单 +│ └── 申请退款 +│ +├── 支付模块 +│ ├── 微信支付 +│ ├── 支付宝支付 +│ └── 支付结果回调 +│ +├── 用户模块 +│ ├── 登录/注册(共享健康AI) +│ ├── 我的体质报告(跳转健康AI) +│ ├── 收货地址管理 +│ ├── 我的订单 +│ ├── 我的收藏 +│ └── 浏览历史 +│ +└── 其他模块 + ├── 物流查询 + └── 优惠券(扩展) +``` + +### 3.2 核心功能说明 + +#### 3.2.1 体质推荐功能 + +从健康AI获取用户体质信息,在商城首页和产品列表页优先展示适合用户体质的产品。 + +``` +用户打开商城 + │ + ▼ +调用健康AI接口获取用户体质 + │ + ▼ +根据体质查询关联产品 + │ + ▼ +在"为您推荐"模块展示 +``` + +#### 3.2.2 智能搜索 + +支持按症状关键词搜索产品,如"失眠"、"关节痛"、"血压高"等。 + +#### 3.2.3 AI健康咨询功能(核心) + +商城内置多个AI咨询入口,用户点击后跳转到健康AI项目进行咨询。 + +**入口位置:** + +| 位置 | 入口形式 | 跳转目标 | 携带参数 | +|------|----------|----------|----------| +| 首页右下角 | 悬浮按钮"AI咨询" | 健康AI对话页 | 无 | +| 首页体质卡片 | "测测我的体质" | 健康AI体质测试页 | 无 | +| 产品详情页 | "这款适合我吗?" | 健康AI对话页 | 产品ID、产品名称 | +| 我的页面 | "查看体质报告" | 健康AI体质结果页 | 无 | +| 购物车页面 | "不知道选哪个?问问AI" | 健康AI对话页 | 购物车产品列表 | + +**跳转流程:** + +``` +商城产品详情页 + │ + │ 用户点击"这款适合我吗?" + ▼ +检查用户登录状态 + │ + ├─[未登录]─▶ 跳转登录页 → 登录后继续 + │ + └─[已登录]─▶ 构建跳转URL + │ + ▼ + 跳转健康AI对话页 + URL: health-ai://chat?product_id=123&product_name=xxx + │ + ▼ + 健康AI自动发起对话 + "我想咨询一下【产品名称】是否适合我" + │ + ▼ + AI根据用户体质和产品信息回答 + │ + ▼ + 用户可继续咨询或返回商城购买 +``` + +**跳转协议设计:** + +``` +# Web端跳转(同域或配置跨域) +https://health-ai.example.com/chat?source=mall&product_id=123 + +# APP端跳转(Deep Link) +health-ai://chat?source=mall&product_id=123&product_name=xxx + +# 小程序跳转(如扩展) +/pages/chat/index?source=mall&product_id=123 +``` + +**健康AI接收参数后处理:** + +1. 识别来源为商城(source=mall) +2. 根据product_id获取产品信息 +3. 自动生成用户问题:"我想了解【产品名称】是否适合我的体质" +4. AI结合用户体质数据回答 + +#### 3.2.4 购买记录同步 + +用户在商城购买保健品后,订单信息同步到健康AI,用于: +- AI问诊时参考用户已购买的保健品 +- 避免重复推荐已购买产品 +- 分析用户保健品使用效果 + +--- + +## 四、数据模型设计 + +### 4.1 ER图 + +```mermaid +erDiagram + User ||--o{ Order : places + User ||--o{ CartItem : has + User ||--o{ Address : has + User ||--o{ Favorite : has + Product ||--o{ ProductSku : has + Product ||--o{ CartItem : in + Product ||--o{ OrderItem : in + Product ||--o{ Favorite : contains + Order ||--|{ OrderItem : contains + Order ||--o| Payment : has + ProductSku ||--o{ CartItem : selected + ProductSku ||--o{ OrderItem : ordered + + User { + int id PK + string phone + string nickname + string avatar + } + + Product { + int id PK + string name + string category + string sub_category + text description + text detail_html + string main_image + json images + decimal original_price + decimal sale_price + int sales_count + int stock + boolean is_on_sale + int sort_order + } + + ProductSku { + int id PK + int product_id FK + string sku_name + string sku_image + decimal price + int stock + json attributes + } + + CartItem { + int id PK + int user_id FK + int product_id FK + int sku_id FK + int quantity + boolean selected + } + + Address { + int id PK + int user_id FK + string receiver_name + string phone + string province + string city + string district + string detail + boolean is_default + } + + Order { + int id PK + string order_no + int user_id FK + int address_id FK + decimal total_amount + decimal pay_amount + decimal freight_amount + string status + string pay_type + datetime pay_time + string logistics_no + text remark + datetime created_at + } + + OrderItem { + int id PK + int order_id FK + int product_id FK + int sku_id FK + string product_name + string sku_name + string image + decimal price + int quantity + } + + Payment { + int id PK + string payment_no + int order_id FK + string pay_type + decimal amount + string status + string trade_no + datetime paid_at + } + + Favorite { + int id PK + int user_id FK + int product_id FK + datetime created_at + } +``` + +### 4.2 核心表说明 + +#### 产品表(Product) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 产品ID(与健康AI同步) | +| name | string | 产品名称 | +| category | string | 一级分类 | +| sub_category | string | 二级分类 | +| description | text | 简短描述 | +| detail_html | text | 详情页富文本 | +| main_image | string | 主图URL | +| images | json | 图片列表 | +| original_price | decimal | 原价 | +| sale_price | decimal | 售价 | +| sales_count | int | 销量 | +| stock | int | 库存 | +| is_on_sale | boolean | 是否上架 | + +#### 订单表(Order) + +| 字段 | 类型 | 说明 | +|------|------|------| +| order_no | string | 订单编号(唯一) | +| status | string | 状态:pending/paid/shipped/completed/cancelled/refunding | +| pay_type | string | 支付方式:wechat/alipay | +| logistics_no | string | 物流单号 | + +### 4.3 订单状态机 + +``` +待支付(pending) + │ + ├──[支付成功]──▶ 待发货(paid) + │ │ + │ ├──[商家发货]──▶ 待收货(shipped) + │ │ │ + │ │ └──[确认收货]──▶ 已完成(completed) + │ │ + │ └──[申请退款]──▶ 退款中(refunding) + │ + └──[取消/超时]──▶ 已取消(cancelled) +``` + +--- + +## 五、API 接口设计 + +### 5.1 产品接口 + +``` +GET /api/products # 产品列表(支持分类、排序、分页) +GET /api/products/:id # 产品详情 +GET /api/products/search?q=xxx # 搜索产品 +GET /api/products/recommend # 体质推荐(调用健康AI) +GET /api/categories # 分类列表 +``` + +### 5.2 购物车接口 + +``` +GET /api/cart # 获取购物车 +POST /api/cart # 添加到购物车 +PUT /api/cart/:id # 更新数量 +DELETE /api/cart/:id # 删除商品 +PUT /api/cart/select-all # 全选/取消全选 +``` + +### 5.3 订单接口 + +``` +POST /api/orders # 创建订单 +GET /api/orders # 订单列表 +GET /api/orders/:id # 订单详情 +PUT /api/orders/:id/cancel # 取消订单 +PUT /api/orders/:id/confirm # 确认收货 +POST /api/orders/:id/refund # 申请退款 +``` + +### 5.4 支付接口 + +``` +POST /api/pay/wechat # 微信支付 +POST /api/pay/alipay # 支付宝支付 +POST /api/pay/callback/wechat # 微信回调 +POST /api/pay/callback/alipay # 支付宝回调 +GET /api/pay/status/:order_no # 查询支付状态 +``` + +### 5.5 地址接口 + +``` +GET /api/addresses # 地址列表 +POST /api/addresses # 添加地址 +PUT /api/addresses/:id # 修改地址 +DELETE /api/addresses/:id # 删除地址 +PUT /api/addresses/:id/default # 设为默认 +``` + +### 5.6 收藏接口 + +``` +GET /api/favorites # 收藏列表 +POST /api/favorites # 添加收藏 +DELETE /api/favorites/:product_id # 取消收藏 +``` + +--- + +## 六、项目目录结构 + +``` +healthMall/ +├── web/ # Web前端 +│ ├── src/ +│ │ ├── api/ # 接口定义 +│ │ ├── assets/ # 静态资源 +│ │ ├── components/ # 公共组件 +│ │ │ ├── ProductCard.vue # 产品卡片 +│ │ │ ├── CartItem.vue # 购物车项 +│ │ │ └── AddressCard.vue # 地址卡片 +│ │ ├── views/ +│ │ │ ├── Home.vue # 首页 +│ │ │ ├── Category.vue # 分类页 +│ │ │ ├── ProductList.vue # 产品列表 +│ │ │ ├── ProductDetail.vue # 产品详情 +│ │ │ ├── Cart.vue # 购物车 +│ │ │ ├── Checkout.vue # 结算页 +│ │ │ ├── OrderList.vue # 订单列表 +│ │ │ ├── OrderDetail.vue # 订单详情 +│ │ │ └── Address.vue # 地址管理 +│ │ ├── store/ # 状态管理 +│ │ ├── router/ # 路由配置 +│ │ └── utils/ # 工具函数 +│ └── package.json +│ +├── app/ # React Native APP +│ ├── src/ +│ │ ├── api/ +│ │ ├── components/ +│ │ ├── screens/ +│ │ ├── navigation/ +│ │ ├── store/ +│ │ └── utils/ +│ └── package.json +│ +├── server/ # 后端服务 +│ ├── cmd/ +│ │ └── main.go +│ ├── internal/ +│ │ ├── api/ +│ │ │ ├── handler/ +│ │ │ │ ├── product.go +│ │ │ │ ├── cart.go +│ │ │ │ ├── order.go +│ │ │ │ ├── payment.go +│ │ │ │ └── address.go +│ │ │ ├── middleware/ +│ │ │ └── router.go +│ │ ├── model/ +│ │ │ ├── product.go +│ │ │ ├── cart.go +│ │ │ ├── order.go +│ │ │ ├── payment.go +│ │ │ └── address.go +│ │ ├── repository/ +│ │ ├── service/ +│ │ │ ├── product.go +│ │ │ ├── cart.go +│ │ │ ├── order.go +│ │ │ └── payment.go +│ │ ├── database/ +│ │ └── config/ +│ ├── pkg/ +│ │ ├── payment/ # 支付SDK封装 +│ │ │ ├── wechat.go +│ │ │ └── alipay.go +│ │ └── logistics/ # 物流查询 +│ └── go.mod +│ +├── config.yaml # 配置文件 +└── README.md +``` + +--- + +## 七、UI 设计规范 + +### 7.1 设计原则 + +- 与健康AI项目保持视觉一致性 +- 面向中老年用户,字体和按钮适当放大 +- 购买流程清晰简洁,减少操作步骤 + +### 7.2 页面规划 + +| 页面 | 主要功能 | AI咨询入口 | +|------|----------|------------| +| 首页 | Banner、分类入口、体质推荐卡片、热销榜 | 右下角悬浮按钮、体质卡片"测测体质" | +| 分类页 | 左侧分类菜单、右侧产品列表 | 顶部"不知道选什么?问AI" | +| 产品详情 | 轮播图、价格、规格选择、加入购物车 | "这款适合我吗?咨询AI" | +| 购物车 | 商品列表、全选、价格汇总、去结算 | "不确定?咨询AI帮你选" | +| 结算页 | 收货地址、商品清单、支付方式、提交订单 | - | +| 订单列表 | Tab分类(全部/待付款/待发货/待收货/已完成) | - | +| 订单详情 | 物流信息、订单状态、商品信息、操作按钮 | "使用问题?咨询AI" | +| 我的 | 体质报告入口、订单、收藏、地址 | "查看我的体质报告" | + +### 7.3 配色方案 + +| 用途 | 颜色 | 说明 | +|------|------|------| +| 主色调 | #52C41A | 绿色,与健康主题一致 | +| 价格色 | #FF4D4F | 红色,突出价格 | +| 辅助色 | #1890FF | 蓝色,链接和按钮 | +| 背景色 | #F5F5F5 | 浅灰,页面背景 | +| 文字色 | #333333 | 深灰,主要文字 | + +--- + +## 八、开发计划 + +### 8.1 阶段划分 + +| 阶段 | 内容 | 预计周期 | +|------|------|----------| +| 第一阶段 | 产品展示、购物车 | - | +| 第二阶段 | 订单流程、地址管理 | - | +| 第三阶段 | 支付对接 | - | +| 第四阶段 | APP开发 | - | +| 第五阶段 | 物流、优惠券等扩展 | - | + +### 8.2 开发任务清单 + +**后端开发:** +1. 项目初始化(共用健康AI的基础架构) +2. 产品模块(详情、SKU、库存) +3. 购物车模块 +4. 地址管理模块 +5. 订单模块(创建、状态流转) +6. 支付模块(微信/支付宝) +7. 健康AI对接(用户认证、体质推荐、订单同步) + +**Web前端开发:** +1. 项目初始化 +2. 首页(Banner、分类、推荐、AI咨询入口) +3. 产品列表和详情页(含AI咨询入口) +4. 购物车页面(含AI咨询入口) +5. 结算和订单页面 +6. 地址管理页面 +7. 我的页面(体质报告入口) +8. 健康AI跳转工具封装 + +**APP开发:** +1. 项目初始化 +2. 导航和Tab配置 +3. 各功能页面 +4. Deep Link跳转健康AI配置 + +--- + +## 九、与健康AI的集成方案 + +### 9.1 用户认证集成 + +```go +// 商城服务验证Token时,调用健康AI的JWT验证 +func ValidateToken(token string) (*UserClaims, error) { + // 使用相同的JWT密钥验证 + // 或调用健康AI的 /api/auth/validate 接口 +} +``` + +### 9.2 体质推荐集成 + +```go +// 商城首页获取用户体质推荐产品 +func GetConstitutionRecommend(userID uint) ([]Product, error) { + // 1. 调用健康AI接口获取用户体质 + // GET http://health-ai-server/api/constitution/result + + // 2. 根据体质查询推荐产品 + // SELECT * FROM products p + // JOIN constitution_products cp ON p.id = cp.product_id + // WHERE cp.constitution_type = ? +} +``` + +### 9.3 AI咨询跳转集成(商城 → 健康AI) + +**Web端跳转工具:** + +```typescript +// utils/healthAI.ts +const HEALTH_AI_BASE_URL = import.meta.env.VITE_HEALTH_AI_URL || 'http://localhost:5173' + +interface JumpParams { + page: 'chat' | 'constitution' | 'result' // 对话页/体质测试/体质结果 + productId?: number + productName?: string + source?: string +} + +// 跳转到健康AI +export function jumpToHealthAI(params: JumpParams) { + const query = new URLSearchParams() + query.set('source', 'mall') + + if (params.productId) { + query.set('product_id', String(params.productId)) + } + if (params.productName) { + query.set('product_name', params.productName) + } + + const url = `${HEALTH_AI_BASE_URL}/${params.page}?${query.toString()}` + + // 同域:直接跳转 + // 跨域:新窗口打开 + window.location.href = url +} + +// 在产品详情页使用 +// jumpToHealthAI({ page: 'chat', productId: 123, productName: '氨糖软骨素' }) +``` + +**APP端跳转(React Native):** + +```typescript +// utils/healthAI.ts +import { Linking, Platform } from 'react-native' + +const HEALTH_AI_SCHEME = 'healthai://' +const HEALTH_AI_WEB_URL = 'https://health-ai.example.com' + +export async function jumpToHealthAI(params: { + page: 'chat' | 'constitution' | 'result' + productId?: number + productName?: string +}) { + const query = `source=mall&product_id=${params.productId || ''}&product_name=${encodeURIComponent(params.productName || '')}` + + // 尝试Deep Link + const deepLink = `${HEALTH_AI_SCHEME}${params.page}?${query}` + + const canOpen = await Linking.canOpenURL(deepLink) + + if (canOpen) { + // 健康AI APP已安装,使用Deep Link + await Linking.openURL(deepLink) + } else { + // 健康AI APP未安装,跳转Web版 + await Linking.openURL(`${HEALTH_AI_WEB_URL}/${params.page}?${query}`) + } +} +``` + +**健康AI接收跳转参数处理:** + +```typescript +// 健康AI项目 - chat页面 +// views/Chat.vue 或 screens/Chat.tsx + +import { useRoute } from 'vue-router' // Vue +// import { useRoute } from '@react-navigation/native' // RN + +const route = useRoute() + +onMounted(() => { + const { source, product_id, product_name } = route.query + + if (source === 'mall' && product_id) { + // 从商城跳转而来,自动发起产品咨询 + const autoMessage = `我想了解【${product_name}】这款产品是否适合我的体质?` + sendMessage(autoMessage) + } +}) +``` + +### 9.4 双向跳转入口汇总 + +| 方向 | 场景 | 入口 | 跳转目标 | +|------|------|------|----------| +| 商城→健康AI | 首页悬浮按钮 | "AI咨询" | 健康AI对话页 | +| 商城→健康AI | 首页体质卡片 | "测测我的体质" | 健康AI体质测试页 | +| 商城→健康AI | 产品详情页 | "这款适合我吗?" | 健康AI对话页(带产品参数) | +| 商城→健康AI | 购物车页 | "不确定选哪个?" | 健康AI对话页 | +| 商城→健康AI | 我的页面 | "查看体质报告" | 健康AI体质结果页 | +| 健康AI→商城 | AI回答推荐 | 产品链接 | 商城产品详情页 | +| 健康AI→商城 | 体质结果页 | "选购调养产品" | 商城体质推荐列表 | + +### 9.5 订单数据同步 + +用户在商城购买后,同步订单到健康AI用于AI问诊参考: + +```go +// 商城后端 - 订单完成后同步 +func SyncOrderToHealthAI(order *Order) error { + payload := map[string]interface{}{ + "user_id": order.UserID, + "order_no": order.OrderNo, + "products": extractProductInfo(order.Items), + "created_at": order.CreatedAt, + } + + // 调用健康AI接口同步 + _, err := http.Post( + config.HealthAI.BaseURL + "/api/sync/purchase", + "application/json", + bytes.NewBuffer(jsonEncode(payload)), + ) + return err +} +``` + +```go +// 健康AI后端 - 接收购买记录 +// POST /api/sync/purchase +func HandlePurchaseSync(c *gin.Context) { + var req struct { + UserID uint `json:"user_id"` + OrderNo string `json:"order_no"` + Products []struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"products"` + } + c.BindJSON(&req) + + // 存储到用户健康档案,AI问诊时可参考 + // "用户最近购买了:氨糖软骨素、深海鱼油..." +} +``` + +--- + +## 十、安全与合规 + +### 10.1 支付安全 + +- 支付回调验签 +- 订单金额校验 +- 防止重复支付 + +### 10.2 数据安全 + +- 用户隐私数据加密存储 +- HTTPS传输 +- SQL注入防护 + +### 10.3 合规要求 + +- 保健品销售资质展示 +- 产品不得宣传治疗功效 +- 退换货政策明示 + +--- + +## 附录:配置文件示例 + +```yaml +# config.yaml +server: + port: 8081 + mode: debug + +database: + driver: sqlite + dsn: "./data/mall.db" + +# 健康AI服务配置 +health_ai: + # 后端API地址(用于数据同步) + api_url: "http://localhost:8080" + # Web前端地址(用于页面跳转) + web_url: "http://localhost:5173" + # APP Deep Link Scheme + app_scheme: "healthai://" + # 同步API密钥 + sync_api_key: "your-sync-key" + +# 支付配置(正式环境从环境变量读取) +payment: + wechat: + app_id: "" + mch_id: "" + api_key: "" + notify_url: "https://yourdomain.com/api/pay/callback/wechat" + alipay: + app_id: "" + private_key: "" + public_key: "" + notify_url: "https://yourdomain.com/api/pay/callback/alipay" + +# 文件存储 +storage: + type: local # local / oss + local: + path: "./uploads" + oss: + endpoint: "" + access_key: "" + secret_key: "" + bucket: "" +``` + +--- + +> **文档版本**: v1.0 +> **创建日期**: 2026-02-01 +> **关联项目**: 健康AI问询助手 diff --git a/scripts/start-all.bat b/scripts/start-all.bat new file mode 100644 index 0000000..02f2aee --- /dev/null +++ b/scripts/start-all.bat @@ -0,0 +1,30 @@ +@echo off +chcp 65001 >nul +echo ==================================== +echo 健康AI助手 - 原型启动脚本 +echo ==================================== +echo. +echo 即将启动: +echo - Web 原型 (http://localhost:5173) +echo - APP 原型 (Expo) +echo. +echo 测试账号: 13800138000 / 123456 +echo ==================================== +echo. + +:: 启动 Web 开发服务器 +start "Web Dev Server" cmd /c "cd /d "%~dp0..\web" && npm run dev" + +:: 等待 2 秒 +timeout /t 2 /nobreak >nul + +:: 启动 APP 开发服务器 +start "APP Dev Server" cmd /c "cd /d "%~dp0..\app" && npx expo start --web" + +echo. +echo 服务器已在新窗口中启动! +echo. +echo Web: http://localhost:5173 +echo APP: http://localhost:8081 (浏览器预览) +echo. +pause diff --git a/scripts/start-app.bat b/scripts/start-app.bat new file mode 100644 index 0000000..ae76336 --- /dev/null +++ b/scripts/start-app.bat @@ -0,0 +1,13 @@ +@echo off +echo ==================================== +echo Starting APP (React Native) +echo ==================================== +echo. +cd /d "%~dp0..\app" +echo Scan QR code to preview on phone +echo Or press 'w' for web preview +echo Test: 13800138000 / 123456 +echo Press Ctrl+C to stop +echo ==================================== +echo. +npx expo start diff --git a/scripts/start-web.bat b/scripts/start-web.bat new file mode 100644 index 0000000..14cd844 --- /dev/null +++ b/scripts/start-web.bat @@ -0,0 +1,12 @@ +@echo off +echo ==================================== +echo Starting Web (Vue 3 + Vite) +echo ==================================== +echo. +cd /d "%~dp0..\web" +echo URL: http://localhost:5173 +echo Test: 13800138000 / 123456 +echo Press Ctrl+C to stop +echo ==================================== +echo. +npm run dev diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go new file mode 100644 index 0000000..c0b2494 --- /dev/null +++ b/server/cmd/server/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "log" + + "health-ai/internal/api" + "health-ai/internal/config" + "health-ai/internal/database" + "health-ai/internal/model" + "health-ai/pkg/jwt" +) + +func main() { + log.Println("Health AI Server Starting...") + + // 加载配置 + if err := config.LoadConfig("config.yaml"); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + log.Println("Config loaded") + + // 初始化数据库 + if err := database.InitDatabase(&config.AppConfig.Database); err != nil { + log.Fatalf("Failed to init database: %v", err) + } + log.Println("Database connected") + + // 自动迁移 + if err := database.AutoMigrate(model.AllModels()...); err != nil { + log.Fatalf("Failed to migrate: %v", err) + } + log.Println("Database migrated") + + // 初始化问卷题库 + if err := database.SeedQuestionBank(); err != nil { + log.Fatalf("Failed to seed question bank: %v", err) + } + + // 创建测试用户 + if err := database.SeedTestUser(); err != nil { + log.Printf("Warning: Failed to create test user: %v", err) + } + + // 初始化 JWT + jwt.Init(config.AppConfig.JWT.Secret, config.AppConfig.JWT.ExpireHours) + log.Println("JWT initialized") + + // 启动服务器 + router := api.SetupRouter(config.AppConfig.Server.Mode) + addr := fmt.Sprintf(":%d", config.AppConfig.Server.Port) + log.Printf("Server running on http://localhost%s", addr) + + if err := router.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/server/config.yaml b/server/config.yaml new file mode 100644 index 0000000..25b5ae7 --- /dev/null +++ b/server/config.yaml @@ -0,0 +1,40 @@ +server: + port: 8080 + mode: debug # debug, release, test + +database: + driver: sqlite # sqlite, postgres, mysql + sqlite: + path: ./data/health.db + postgres: + host: localhost + port: 5432 + user: postgres + password: "" + dbname: health_app + mysql: + host: localhost + port: 3306 + user: root + password: "" + dbname: health_app + +jwt: + secret: health-ai-secret-key-change-in-production + expire_hours: 24 + +ai: + provider: aliyun # openai, aliyun (通义千问) + max_history_messages: 10 + max_tokens: 2000 + + # OpenAI 配置 + openai: + api_key: "" + base_url: "https://api.openai.com/v1" + model: "gpt-3.5-turbo" + + # 阿里云通义千问配置 + aliyun: + api_key: "" # 请填入您的 DashScope API Key + model: "qwen-turbo" # 可选: qwen-turbo, qwen-plus, qwen-max diff --git a/server/data/health.db b/server/data/health.db new file mode 100644 index 0000000..61f37f2 Binary files /dev/null and b/server/data/health.db differ diff --git a/server/docs/API.md b/server/docs/API.md new file mode 100644 index 0000000..1a6c019 --- /dev/null +++ b/server/docs/API.md @@ -0,0 +1,551 @@ +# 健康AI问询助手 - 后端API文档 + +> 后端服务地址: `http://localhost:8080` +> +> 所有需要认证的接口,请在Header中添加: `Authorization: Bearer ` + +--- + +## 一、认证接口 + +### 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 或联系后端开发团队。 diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..3984d9d --- /dev/null +++ b/server/go.mod @@ -0,0 +1,58 @@ +module health-ai + +go 1.25.5 + +require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.47.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..aa71747 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,133 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/server/internal/api/handler/auth.go b/server/internal/api/handler/auth.go new file mode 100644 index 0000000..75a795f --- /dev/null +++ b/server/internal/api/handler/auth.go @@ -0,0 +1,141 @@ +package handler + +import ( + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService *service.AuthService +} + +func NewAuthHandler() *AuthHandler { + return &AuthHandler{ + authService: service.NewAuthService(), + } +} + +// Register 用户注册 +// @Summary 用户注册 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param request body service.RegisterRequest true "注册信息" +// @Success 200 {object} response.Response{data=service.AuthResponse} +// @Router /api/auth/register [post] +func (h *AuthHandler) Register(c *gin.Context) { + var req service.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + result, err := h.authService.Register(&req) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.Success(c, result) +} + +// Login 用户登录 +// @Summary 用户登录 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param request body service.LoginRequest true "登录信息" +// @Success 200 {object} response.Response{data=service.AuthResponse} +// @Router /api/auth/login [post] +func (h *AuthHandler) Login(c *gin.Context) { + var req service.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + result, err := h.authService.Login(&req) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.Success(c, result) +} + +// RefreshToken 刷新Token +// @Summary 刷新Token +// @Tags 认证 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=map[string]string} +// @Router /api/auth/refresh [post] +func (h *AuthHandler) RefreshToken(c *gin.Context) { + // 从header获取旧token + oldToken := c.GetHeader("Authorization") + if len(oldToken) > 7 { + oldToken = oldToken[7:] // 去掉 "Bearer " + } + + newToken, err := h.authService.RefreshToken(oldToken) + if err != nil { + response.Unauthorized(c, "Token刷新失败") + return + } + + response.Success(c, gin.H{"token": newToken}) +} + +// GetUserInfo 获取当前用户信息 +// @Summary 获取当前用户信息 +// @Tags 用户 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=service.UserInfoResponse} +// @Router /api/user/profile [get] +func (h *AuthHandler) GetUserInfo(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.authService.GetUserInfo(userID) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + response.Success(c, result) +} + +// UpdateProfile 更新用户资料 +// @Summary 更新用户资料 +// @Tags 用户 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body UpdateProfileRequest true "更新信息" +// @Success 200 {object} response.Response +// @Router /api/user/profile [put] +func (h *AuthHandler) UpdateProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.authService.UpdateProfile(userID, req.Nickname, req.Avatar); err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.SuccessWithMessage(c, "更新成功", nil) +} + +// UpdateProfileRequest 更新资料请求 +type UpdateProfileRequest struct { + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} diff --git a/server/internal/api/handler/constitution.go b/server/internal/api/handler/constitution.go new file mode 100644 index 0000000..16378fd --- /dev/null +++ b/server/internal/api/handler/constitution.go @@ -0,0 +1,155 @@ +package handler + +import ( + "strconv" + + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type ConstitutionHandler struct { + constitutionService *service.ConstitutionService +} + +func NewConstitutionHandler() *ConstitutionHandler { + return &ConstitutionHandler{ + constitutionService: service.NewConstitutionService(), + } +} + +// GetQuestions 获取体质问卷题目 +// @Summary 获取体质问卷题目 +// @Tags 体质辨识 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=[]model.QuestionBank} +// @Router /api/constitution/questions [get] +func (h *ConstitutionHandler) GetQuestions(c *gin.Context) { + questions, err := h.constitutionService.GetQuestions() + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, questions) +} + +// GetQuestionsGrouped 获取分组的体质问卷题目 +// @Summary 获取分组的体质问卷题目 +// @Tags 体质辨识 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=[]service.QuestionGroup} +// @Router /api/constitution/questions/grouped [get] +func (h *ConstitutionHandler) GetQuestionsGrouped(c *gin.Context) { + groups, err := h.constitutionService.GetQuestionsGrouped() + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, groups) +} + +// SubmitAssessment 提交体质测评 +// @Summary 提交体质测评答案 +// @Tags 体质辨识 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.SubmitAssessmentRequest true "测评答案" +// @Success 200 {object} response.Response{data=service.AssessmentResult} +// @Router /api/constitution/submit [post] +func (h *ConstitutionHandler) SubmitAssessment(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req service.SubmitAssessmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if len(req.Answers) == 0 { + response.BadRequest(c, "请至少回答一道问题") + return + } + + result, err := h.constitutionService.SubmitAssessment(userID, &req) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, result) +} + +// GetResult 获取最新体质测评结果 +// @Summary 获取最新体质测评结果 +// @Tags 体质辨识 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=service.AssessmentResult} +// @Router /api/constitution/result [get] +func (h *ConstitutionHandler) GetResult(c *gin.Context) { + userID := middleware.GetUserID(c) + + result, err := h.constitutionService.GetLatestResult(userID) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + + response.Success(c, result) +} + +// GetHistory 获取体质测评历史 +// @Summary 获取体质测评历史 +// @Tags 体质辨识 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param limit query int false "返回数量限制,默认10" +// @Success 200 {object} response.Response{data=[]service.AssessmentResult} +// @Router /api/constitution/history [get] +func (h *ConstitutionHandler) GetHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + + limit := 10 + if limitStr := c.Query("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + results, err := h.constitutionService.GetHistory(userID, limit) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.Success(c, results) +} + +// GetRecommendations 获取体质调养建议 +// @Summary 获取体质调养建议 +// @Tags 体质辨识 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=map[string]map[string]string} +// @Router /api/constitution/recommendations [get] +func (h *ConstitutionHandler) GetRecommendations(c *gin.Context) { + userID := middleware.GetUserID(c) + + recommendations, err := h.constitutionService.GetRecommendations(userID) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + + response.Success(c, recommendations) +} diff --git a/server/internal/api/handler/conversation.go b/server/internal/api/handler/conversation.go new file mode 100644 index 0000000..ffc75f3 --- /dev/null +++ b/server/internal/api/handler/conversation.go @@ -0,0 +1,183 @@ +package handler + +import ( + "strconv" + + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type ConversationHandler struct { + convService *service.ConversationService +} + +func NewConversationHandler() *ConversationHandler { + return &ConversationHandler{ + convService: service.NewConversationService(), + } +} + +// GetConversations 获取对话列表 +// @Summary 获取对话列表 +// @Tags AI对话 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=[]service.ConversationResponse} +// @Router /api/conversations [get] +func (h *ConversationHandler) GetConversations(c *gin.Context) { + userID := middleware.GetUserID(c) + convs, err := h.convService.GetConversations(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, convs) +} + +// CreateConversation 创建新对话 +// @Summary 创建新对话 +// @Tags AI对话 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.CreateConversationRequest true "对话标题(可选)" +// @Success 200 {object} response.Response{data=service.ConversationResponse} +// @Router /api/conversations [post] +func (h *ConversationHandler) CreateConversation(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.CreateConversationRequest + c.ShouldBindJSON(&req) + + conv, err := h.convService.CreateConversation(userID, req.Title) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, conv) +} + +// GetConversation 获取对话详情 +// @Summary 获取对话详情(含消息历史) +// @Tags AI对话 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param id path int true "对话ID" +// @Success 200 {object} response.Response{data=service.ConversationResponse} +// @Router /api/conversations/{id} [get] +func (h *ConversationHandler) GetConversation(c *gin.Context) { + userID := middleware.GetUserID(c) + convID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的对话ID") + return + } + + conv, err := h.convService.GetConversation(userID, uint(convID)) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + response.Success(c, conv) +} + +// DeleteConversation 删除对话 +// @Summary 删除对话 +// @Tags AI对话 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param id path int true "对话ID" +// @Success 200 {object} response.Response +// @Router /api/conversations/{id} [delete] +func (h *ConversationHandler) DeleteConversation(c *gin.Context) { + userID := middleware.GetUserID(c) + convID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的对话ID") + return + } + + if err := h.convService.DeleteConversation(userID, uint(convID)); err != nil { + response.Error(c, 400, err.Error()) + return + } + response.SuccessWithMessage(c, "对话已删除", nil) +} + +// SendMessage 发送消息 +// @Summary 发送消息并获取AI回复 +// @Tags AI对话 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param id path int true "对话ID" +// @Param request body service.SendMessageRequest true "消息内容" +// @Success 200 {object} response.Response{data=service.MessageResponse} +// @Router /api/conversations/{id}/messages [post] +func (h *ConversationHandler) SendMessage(c *gin.Context) { + userID := middleware.GetUserID(c) + convID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的对话ID") + return + } + + var req service.SendMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请输入消息内容") + return + } + + if req.Content == "" { + response.BadRequest(c, "消息内容不能为空") + return + } + + reply, err := h.convService.SendMessage(c.Request.Context(), userID, uint(convID), req.Content) + if err != nil { + response.Error(c, 500, "AI回复失败: "+err.Error()) + return + } + + response.Success(c, reply) +} + +// SendMessageStream 流式发送消息(SSE) +// @Summary 流式发送消息 +// @Tags AI对话 +// @Accept json +// @Produce text/event-stream +// @Param Authorization header string true "Bearer Token" +// @Param id path int true "对话ID" +// @Param request body service.SendMessageRequest true "消息内容" +// @Router /api/conversations/{id}/messages/stream [post] +func (h *ConversationHandler) SendMessageStream(c *gin.Context) { + userID := middleware.GetUserID(c) + convID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的对话ID") + return + } + + var req service.SendMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请输入消息内容") + return + } + + // 设置 SSE 响应头 + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + err = h.convService.SendMessageStream(c.Request.Context(), userID, uint(convID), req.Content, c.Writer) + if err != nil { + c.SSEvent("error", err.Error()) + } + c.SSEvent("done", "") +} diff --git a/server/internal/api/handler/health.go b/server/internal/api/handler/health.go new file mode 100644 index 0000000..ca48e9b --- /dev/null +++ b/server/internal/api/handler/health.go @@ -0,0 +1,201 @@ +package handler + +import ( + "strconv" + + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type HealthHandler struct { + healthService *service.HealthService +} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{ + healthService: service.NewHealthService(), + } +} + +// GetHealthProfile 获取完整健康档案 +// @Summary 获取完整健康档案 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=service.HealthProfileResponse} +// @Router /api/user/health-profile [get] +func (h *HealthHandler) GetHealthProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + profile, err := h.healthService.GetHealthProfile(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, profile) +} + +// GetBasicProfile 获取基础档案 +// @Summary 获取基础档案 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=model.HealthProfile} +// @Router /api/user/basic-profile [get] +func (h *HealthHandler) GetBasicProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + profile, err := h.healthService.GetBasicProfile(userID) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + response.Success(c, profile) +} + +// GetLifestyle 获取生活习惯 +// @Summary 获取生活习惯 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=model.LifestyleInfo} +// @Router /api/user/lifestyle [get] +func (h *HealthHandler) GetLifestyle(c *gin.Context) { + userID := middleware.GetUserID(c) + lifestyle, err := h.healthService.GetLifestyle(userID) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + response.Success(c, lifestyle) +} + +// GetMedicalHistory 获取病史列表 +// @Summary 获取病史列表 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=[]model.MedicalHistory} +// @Router /api/user/medical-history [get] +func (h *HealthHandler) GetMedicalHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + history, err := h.healthService.GetMedicalHistory(userID) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + response.Success(c, history) +} + +// GetFamilyHistory 获取家族病史列表 +// @Summary 获取家族病史列表 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=[]model.FamilyHistory} +// @Router /api/user/family-history [get] +func (h *HealthHandler) GetFamilyHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + history, err := h.healthService.GetFamilyHistory(userID) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + response.Success(c, history) +} + +// GetAllergyRecords 获取过敏记录列表 +// @Summary 获取过敏记录列表 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=[]model.AllergyRecord} +// @Router /api/user/allergy-records [get] +func (h *HealthHandler) GetAllergyRecords(c *gin.Context) { + userID := middleware.GetUserID(c) + records, err := h.healthService.GetAllergyRecords(userID) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + response.Success(c, records) +} + +// DeleteMedicalHistory 删除病史记录 +// @Summary 删除病史记录 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param id path int true "记录ID" +// @Success 200 {object} response.Response +// @Router /api/user/medical-history/{id} [delete] +func (h *HealthHandler) DeleteMedicalHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + if err := h.healthService.DeleteMedicalHistory(userID, uint(id)); err != nil { + response.Error(c, 500, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// DeleteFamilyHistory 删除家族病史记录 +// @Summary 删除家族病史记录 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param id path int true "记录ID" +// @Success 200 {object} response.Response +// @Router /api/user/family-history/{id} [delete] +func (h *HealthHandler) DeleteFamilyHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + if err := h.healthService.DeleteFamilyHistory(userID, uint(id)); err != nil { + response.Error(c, 500, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// DeleteAllergyRecord 删除过敏记录 +// @Summary 删除过敏记录 +// @Tags 健康档案 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param id path int true "记录ID" +// @Success 200 {object} response.Response +// @Router /api/user/allergy-records/{id} [delete] +func (h *HealthHandler) DeleteAllergyRecord(c *gin.Context) { + userID := middleware.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + if err := h.healthService.DeleteAllergyRecord(userID, uint(id)); err != nil { + response.Error(c, 500, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} diff --git a/server/internal/api/handler/product.go b/server/internal/api/handler/product.go new file mode 100644 index 0000000..009335e --- /dev/null +++ b/server/internal/api/handler/product.go @@ -0,0 +1,172 @@ +package handler + +import ( + "strconv" + + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type ProductHandler struct { + productService *service.ProductService +} + +func NewProductHandler() *ProductHandler { + return &ProductHandler{ + productService: service.NewProductService(), + } +} + +// GetProducts 获取产品列表 +// @Summary 获取产品列表 +// @Tags 产品 +// @Accept json +// @Produce json +// @Param page query int false "页码,默认1" +// @Param page_size query int false "每页数量,默认20" +// @Success 200 {object} response.Response{data=service.ProductListResponse} +// @Router /api/products [get] +func (h *ProductHandler) GetProducts(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + result, err := h.productService.GetProducts(page, pageSize) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, result) +} + +// GetProduct 获取产品详情 +// @Summary 获取产品详情 +// @Tags 产品 +// @Accept json +// @Produce json +// @Param id path int true "产品ID" +// @Success 200 {object} response.Response{data=model.Product} +// @Router /api/products/{id} [get] +func (h *ProductHandler) GetProduct(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的产品ID") + return + } + + product, err := h.productService.GetProductByID(uint(id)) + if err != nil { + response.Error(c, 404, err.Error()) + return + } + response.Success(c, product) +} + +// GetProductsByCategory 按分类获取产品 +// @Summary 按分类获取产品 +// @Tags 产品 +// @Accept json +// @Produce json +// @Param category query string true "分类名称" +// @Success 200 {object} response.Response{data=[]model.Product} +// @Router /api/products/category [get] +func (h *ProductHandler) GetProductsByCategory(c *gin.Context) { + category := c.Query("category") + if category == "" { + response.BadRequest(c, "请指定分类") + return + } + + products, err := h.productService.GetProductsByCategory(category) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, products) +} + +// GetRecommendedProducts 获取推荐产品(基于用户体质) +// @Summary 获取推荐产品 +// @Tags 产品 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=service.ProductRecommendResponse} +// @Router /api/products/recommend [get] +func (h *ProductHandler) GetRecommendedProducts(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.productService.GetRecommendedProducts(userID) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + response.Success(c, result) +} + +// SearchProducts 搜索产品 +// @Summary 搜索产品 +// @Tags 产品 +// @Accept json +// @Produce json +// @Param keyword query string true "搜索关键词" +// @Success 200 {object} response.Response{data=[]model.Product} +// @Router /api/products/search [get] +func (h *ProductHandler) SearchProducts(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + response.BadRequest(c, "请输入搜索关键词") + return + } + + products, err := h.productService.SearchProducts(keyword) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, products) +} + +// SyncPurchase 同步商城购买记录 +// @Summary 同步商城购买记录 +// @Tags 商城同步 +// @Accept json +// @Produce json +// @Param request body service.PurchaseSyncRequest true "购买记录" +// @Success 200 {object} response.Response +// @Router /api/sync/purchase [post] +func (h *ProductHandler) SyncPurchase(c *gin.Context) { + // TODO: 验证同步密钥 + + var req service.PurchaseSyncRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.productService.SyncPurchase(&req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "同步成功", nil) +} + +// GetPurchaseHistory 获取购买历史 +// @Summary 获取购买历史 +// @Tags 产品 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=[]model.PurchaseHistory} +// @Router /api/user/purchase-history [get] +func (h *ProductHandler) GetPurchaseHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + history, err := h.productService.GetPurchaseHistory(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, history) +} diff --git a/server/internal/api/handler/survey.go b/server/internal/api/handler/survey.go new file mode 100644 index 0000000..f9abf60 --- /dev/null +++ b/server/internal/api/handler/survey.go @@ -0,0 +1,256 @@ +package handler + +import ( + "health-ai/internal/api/middleware" + "health-ai/internal/service" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +type SurveyHandler struct { + surveyService *service.SurveyService +} + +func NewSurveyHandler() *SurveyHandler { + return &SurveyHandler{ + surveyService: service.NewSurveyService(), + } +} + +// GetStatus 获取调查完成状态 +// @Summary 获取调查完成状态 +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response{data=service.SurveyStatusResponse} +// @Router /api/survey/status [get] +func (h *SurveyHandler) GetStatus(c *gin.Context) { + userID := middleware.GetUserID(c) + status, err := h.surveyService.GetStatus(userID) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.Success(c, status) +} + +// SubmitBasicInfo 提交基础信息 +// @Summary 提交基础信息 +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.BasicInfoRequest true "基础信息" +// @Success 200 {object} response.Response +// @Router /api/survey/basic-info [post] +func (h *SurveyHandler) SubmitBasicInfo(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.BasicInfoRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitBasicInfo(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "基础信息保存成功", nil) +} + +// SubmitLifestyle 提交生活习惯 +// @Summary 提交生活习惯 +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.LifestyleRequest true "生活习惯" +// @Success 200 {object} response.Response +// @Router /api/survey/lifestyle [post] +func (h *SurveyHandler) SubmitLifestyle(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.LifestyleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitLifestyle(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "生活习惯保存成功", nil) +} + +// SubmitMedicalHistory 提交病史(单条) +// @Summary 提交病史 +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.MedicalHistoryRequest true "病史信息" +// @Success 200 {object} response.Response +// @Router /api/survey/medical-history [post] +func (h *SurveyHandler) SubmitMedicalHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.MedicalHistoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitMedicalHistory(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "病史记录添加成功", nil) +} + +// SubmitBatchMedicalHistory 批量提交病史 +// @Summary 批量提交病史(覆盖式) +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.BatchMedicalHistoryRequest true "病史列表" +// @Success 200 {object} response.Response +// @Router /api/survey/medical-history/batch [post] +func (h *SurveyHandler) SubmitBatchMedicalHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.BatchMedicalHistoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitBatchMedicalHistory(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "病史记录保存成功", nil) +} + +// SubmitFamilyHistory 提交家族病史(单条) +// @Summary 提交家族病史 +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.FamilyHistoryRequest true "家族病史" +// @Success 200 {object} response.Response +// @Router /api/survey/family-history [post] +func (h *SurveyHandler) SubmitFamilyHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.FamilyHistoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitFamilyHistory(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "家族病史记录添加成功", nil) +} + +// SubmitBatchFamilyHistory 批量提交家族病史 +// @Summary 批量提交家族病史(覆盖式) +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.BatchFamilyHistoryRequest true "家族病史列表" +// @Success 200 {object} response.Response +// @Router /api/survey/family-history/batch [post] +func (h *SurveyHandler) SubmitBatchFamilyHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.BatchFamilyHistoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitBatchFamilyHistory(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "家族病史保存成功", nil) +} + +// SubmitAllergy 提交过敏信息(单条) +// @Summary 提交过敏信息 +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.AllergyRequest true "过敏信息" +// @Success 200 {object} response.Response +// @Router /api/survey/allergy [post] +func (h *SurveyHandler) SubmitAllergy(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.AllergyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitAllergy(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "过敏信息添加成功", nil) +} + +// SubmitBatchAllergy 批量提交过敏信息 +// @Summary 批量提交过敏信息(覆盖式) +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Param request body service.BatchAllergyRequest true "过敏信息列表" +// @Success 200 {object} response.Response +// @Router /api/survey/allergy/batch [post] +func (h *SurveyHandler) SubmitBatchAllergy(c *gin.Context) { + userID := middleware.GetUserID(c) + var req service.BatchAllergyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := h.surveyService.SubmitBatchAllergy(userID, &req); err != nil { + response.Error(c, 500, err.Error()) + return + } + + response.SuccessWithMessage(c, "过敏信息保存成功", nil) +} + +// CompleteSurvey 完成调查 +// @Summary 完成健康调查 +// @Tags 健康调查 +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer Token" +// @Success 200 {object} response.Response +// @Router /api/survey/complete [post] +func (h *SurveyHandler) CompleteSurvey(c *gin.Context) { + userID := middleware.GetUserID(c) + + if err := h.surveyService.CompleteSurvey(userID); err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.SuccessWithMessage(c, "健康调查已完成", nil) +} diff --git a/server/internal/api/middleware/auth.go b/server/internal/api/middleware/auth.go new file mode 100644 index 0000000..f5c40be --- /dev/null +++ b/server/internal/api/middleware/auth.go @@ -0,0 +1,72 @@ +package middleware + +import ( + "strings" + + "health-ai/pkg/jwt" + "health-ai/pkg/response" + + "github.com/gin-gonic/gin" +) + +// AuthRequired JWT认证中间件 +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.Unauthorized(c, "未提供认证信息") + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + response.Unauthorized(c, "认证格式错误,请使用 Bearer Token") + c.Abort() + return + } + + claims, err := jwt.ParseToken(parts[1]) + if err != nil { + response.Unauthorized(c, "Token无效或已过期") + c.Abort() + return + } + + // 将用户ID存入上下文 + c.Set("userID", claims.UserID) + c.Next() + } +} + +// GetUserID 从上下文获取用户ID +func GetUserID(c *gin.Context) uint { + userID, exists := c.Get("userID") + if !exists { + return 0 + } + return userID.(uint) +} + +// OptionalAuth 可选认证中间件(不强制要求登录) +func OptionalAuth() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.Next() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.Next() + return + } + + claims, err := jwt.ParseToken(parts[1]) + if err == nil { + c.Set("userID", claims.UserID) + } + c.Next() + } +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go new file mode 100644 index 0000000..a13f702 --- /dev/null +++ b/server/internal/api/router.go @@ -0,0 +1,136 @@ +package api + +import ( + "health-ai/internal/api/handler" + "health-ai/internal/api/middleware" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func SetupRouter(mode string) *gin.Engine { + gin.SetMode(mode) + r := gin.Default() + + // 跨域配置 + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + AllowCredentials: true, + })) + + // 健康检查 + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // API 路由组 + apiGroup := r.Group("/api") + { + // ===================== + // 认证路由(无需登录) + // ===================== + authHandler := handler.NewAuthHandler() + authGroup := apiGroup.Group("/auth") + { + authGroup.POST("/register", authHandler.Register) + authGroup.POST("/login", authHandler.Login) + authGroup.POST("/refresh", authHandler.RefreshToken) + } + + // ===================== + // 产品路由(部分无需登录) + // ===================== + productHandler := handler.NewProductHandler() + productGroup := apiGroup.Group("/products") + { + productGroup.GET("", productHandler.GetProducts) + productGroup.GET("/:id", productHandler.GetProduct) + productGroup.GET("/category", productHandler.GetProductsByCategory) + productGroup.GET("/search", productHandler.SearchProducts) + } + + // 商城同步接口(无需用户登录,需要API密钥验证) + apiGroup.POST("/sync/purchase", productHandler.SyncPurchase) + + // ===================== + // 需要登录的路由 + // ===================== + authRequired := apiGroup.Group("") + authRequired.Use(middleware.AuthRequired()) + { + // ===================== + // 用户相关 + // ===================== + authRequired.GET("/user/profile", authHandler.GetUserInfo) + authRequired.PUT("/user/profile", authHandler.UpdateProfile) + + // 健康档案 + healthHandler := handler.NewHealthHandler() + authRequired.GET("/user/health-profile", healthHandler.GetHealthProfile) + authRequired.GET("/user/basic-profile", healthHandler.GetBasicProfile) + authRequired.GET("/user/lifestyle", healthHandler.GetLifestyle) + authRequired.GET("/user/medical-history", healthHandler.GetMedicalHistory) + authRequired.DELETE("/user/medical-history/:id", healthHandler.DeleteMedicalHistory) + authRequired.GET("/user/family-history", healthHandler.GetFamilyHistory) + authRequired.DELETE("/user/family-history/:id", healthHandler.DeleteFamilyHistory) + authRequired.GET("/user/allergy-records", healthHandler.GetAllergyRecords) + authRequired.DELETE("/user/allergy-records/:id", healthHandler.DeleteAllergyRecord) + + // 购买历史 + authRequired.GET("/user/purchase-history", productHandler.GetPurchaseHistory) + + // 产品推荐(需要登录获取体质) + authRequired.GET("/products/recommend", productHandler.GetRecommendedProducts) + + // ===================== + // 健康调查路由 + // ===================== + surveyHandler := handler.NewSurveyHandler() + surveyGroup := authRequired.Group("/survey") + { + surveyGroup.GET("/status", surveyHandler.GetStatus) + surveyGroup.POST("/basic-info", surveyHandler.SubmitBasicInfo) + surveyGroup.POST("/lifestyle", surveyHandler.SubmitLifestyle) + surveyGroup.POST("/medical-history", surveyHandler.SubmitMedicalHistory) + surveyGroup.POST("/medical-history/batch", surveyHandler.SubmitBatchMedicalHistory) + surveyGroup.POST("/family-history", surveyHandler.SubmitFamilyHistory) + surveyGroup.POST("/family-history/batch", surveyHandler.SubmitBatchFamilyHistory) + surveyGroup.POST("/allergy", surveyHandler.SubmitAllergy) + surveyGroup.POST("/allergy/batch", surveyHandler.SubmitBatchAllergy) + surveyGroup.POST("/complete", surveyHandler.CompleteSurvey) + } + + // ===================== + // 体质辨识路由 + // ===================== + constitutionHandler := handler.NewConstitutionHandler() + constitutionGroup := authRequired.Group("/constitution") + { + constitutionGroup.GET("/questions", constitutionHandler.GetQuestions) + constitutionGroup.GET("/questions/grouped", constitutionHandler.GetQuestionsGrouped) + constitutionGroup.POST("/submit", constitutionHandler.SubmitAssessment) + constitutionGroup.GET("/result", constitutionHandler.GetResult) + constitutionGroup.GET("/history", constitutionHandler.GetHistory) + constitutionGroup.GET("/recommendations", constitutionHandler.GetRecommendations) + } + + // ===================== + // AI对话路由 + // ===================== + conversationHandler := handler.NewConversationHandler() + convGroup := authRequired.Group("/conversations") + { + convGroup.GET("", conversationHandler.GetConversations) + convGroup.POST("", conversationHandler.CreateConversation) + convGroup.GET("/:id", conversationHandler.GetConversation) + convGroup.DELETE("/:id", conversationHandler.DeleteConversation) + convGroup.POST("/:id/messages", conversationHandler.SendMessage) + convGroup.POST("/:id/messages/stream", conversationHandler.SendMessageStream) + } + } + } + + return r +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..50d0e43 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,79 @@ +package config + +import ( + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + JWT JWTConfig `mapstructure:"jwt"` + AI AIConfig `mapstructure:"ai"` +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` +} + +type DatabaseConfig struct { + Driver string `mapstructure:"driver"` + SQLite SQLiteConfig `mapstructure:"sqlite"` + Postgres PostgresConfig `mapstructure:"postgres"` + MySQL MySQLConfig `mapstructure:"mysql"` +} + +type SQLiteConfig struct { + Path string `mapstructure:"path"` +} + +type PostgresConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` +} + +type MySQLConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` +} + +type JWTConfig struct { + Secret string `mapstructure:"secret"` + ExpireHours int `mapstructure:"expire_hours"` +} + +type AIConfig struct { + Provider string `mapstructure:"provider"` + MaxHistoryMessages int `mapstructure:"max_history_messages"` + MaxTokens int `mapstructure:"max_tokens"` + OpenAI OpenAIConfig `mapstructure:"openai"` + Aliyun AliyunConfig `mapstructure:"aliyun"` +} + +type OpenAIConfig struct { + APIKey string `mapstructure:"api_key"` + BaseURL string `mapstructure:"base_url"` + Model string `mapstructure:"model"` +} + +type AliyunConfig struct { + APIKey string `mapstructure:"api_key"` + Model string `mapstructure:"model"` +} + +var AppConfig *Config + +func LoadConfig(path string) error { + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + return err + } + AppConfig = &Config{} + return viper.Unmarshal(AppConfig) +} diff --git a/server/internal/database/database.go b/server/internal/database/database.go new file mode 100644 index 0000000..20fe331 --- /dev/null +++ b/server/internal/database/database.go @@ -0,0 +1,50 @@ +package database + +import ( + "fmt" + + "health-ai/internal/config" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func InitDatabase(cfg *config.DatabaseConfig) error { + var err error + + gormConfig := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } + + switch cfg.Driver { + case "sqlite": + DB, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), gormConfig) + case "postgres": + // TODO: 添加 PostgreSQL 支持 + // dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + // cfg.Postgres.Host, cfg.Postgres.Port, cfg.Postgres.User, cfg.Postgres.Password, cfg.Postgres.DBName) + // DB, err = gorm.Open(postgres.Open(dsn), gormConfig) + return fmt.Errorf("postgres driver not implemented yet") + case "mysql": + // TODO: 添加 MySQL 支持 + // dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + // cfg.MySQL.User, cfg.MySQL.Password, cfg.MySQL.Host, cfg.MySQL.Port, cfg.MySQL.DBName) + // DB, err = gorm.Open(mysql.Open(dsn), gormConfig) + return fmt.Errorf("mysql driver not implemented yet") + default: + return fmt.Errorf("unsupported database driver: %s", cfg.Driver) + } + + return err +} + +func AutoMigrate(models ...interface{}) error { + return DB.AutoMigrate(models...) +} + +func GetDB() *gorm.DB { + return DB +} diff --git a/server/internal/database/seed.go b/server/internal/database/seed.go new file mode 100644 index 0000000..813d2ca --- /dev/null +++ b/server/internal/database/seed.go @@ -0,0 +1,151 @@ +package database + +import ( + "encoding/json" + "log" + + "health-ai/internal/model" + + "golang.org/x/crypto/bcrypt" +) + +// SeedTestUser 创建测试用户 +func SeedTestUser() error { + var count int64 + DB.Model(&model.User{}).Where("phone = ?", "13800138000").Count(&count) + if count > 0 { + log.Println("Test user already exists") + return nil + } + + // 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) + if err != nil { + return err + } + + testUser := &model.User{ + Phone: "13800138000", + PasswordHash: string(hashedPassword), + Nickname: "测试用户", + } + + if err := DB.Create(testUser).Error; err != nil { + return err + } + + log.Println("Test user created: phone=13800138000, password=123456") + return nil +} + +// SeedQuestionBank 初始化问卷题库 +func SeedQuestionBank() error { + // 检查是否已有数据 + var count int64 + DB.Model(&model.QuestionBank{}).Count(&count) + if count > 0 { + log.Printf("Question bank already seeded with %d questions", count) + return nil + } + + questions := getQuestions() + for _, q := range questions { + if err := DB.Create(&q).Error; err != nil { + return err + } + } + log.Printf("Question bank seeded with %d questions", len(questions)) + return nil +} + +func getQuestions() []model.QuestionBank { + options, _ := json.Marshal([]string{"没有", "很少", "有时", "经常", "总是"}) + optStr := string(options) + + return []model.QuestionBank{ + // 平和质 (8题) + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您精力充沛吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您能适应外界自然和社会环境的变化吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易失眠吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionPinghe, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 8}, + + // 气虚质 (8题) + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易气短(呼吸短促,接不上气)吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易心慌吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易头晕或站起时晕眩吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您喜欢安静、懒得说话吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您说话声音低弱无力吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您活动量稍大就容易出虚汗吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionQixu, QuestionText: "您容易疲乏吗?", Options: optStr, OrderNum: 8}, + + // 阳虚质 (7题) + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您手脚发凉吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您胃脘部、背部或腰膝部怕冷吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您穿的衣服总比别人多吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比一般人耐受不了寒冷吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您比别人容易感冒吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您吃凉东西会感到不舒服或怕吃凉东西吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionYangxu, QuestionText: "您受凉或吃凉的东西后,容易拉肚子吗?", Options: optStr, OrderNum: 7}, + + // 阴虚质 (8题) + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到手脚心发热吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感觉身体、脸上发热吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您皮肤或口唇干吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您口唇的颜色比一般人红吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您容易便秘或大便干燥吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您面部两颧潮红或偏红吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到眼睛干涩吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionYinxu, QuestionText: "您感到口干咽燥、总想喝水吗?", Options: optStr, OrderNum: 8}, + + // 痰湿质 (8题) + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到胸闷或腹部胀满吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您感到身体沉重不轻松或不爽快吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您腹部肥满松软吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您额头部位油脂分泌多吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您上眼睑比别人肿吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您嘴里有黏黏的感觉吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您平时痰多吗?", Options: optStr, OrderNum: 7}, + {ConstitutionType: model.ConstitutionTanshi, QuestionText: "您舌苔厚腻或有舌苔厚厚的感觉吗?", Options: optStr, OrderNum: 8}, + + // 湿热质 (7题) + {ConstitutionType: model.ConstitutionShire, QuestionText: "您面部或鼻部有油腻感或油光发亮吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您脸上容易生痤疮或皮肤容易生疮疖吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您感到口苦或嘴里有异味吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您大便黏滞不爽、有解不尽的感觉吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您小便时尿道有发热感、尿色浓吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您带下色黄(白带颜色发黄)吗?(限女性回答)", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionShire, QuestionText: "您的阴囊部位潮湿吗?(限男性回答)", Options: optStr, OrderNum: 7}, + + // 血瘀质 (7题) + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您皮肤在不知不觉中会出现青紫瘀斑吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您两颧部有细微红丝吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您身体上有哪里疼痛吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您面色晦暗或容易出现褐斑吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易有黑眼圈吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您容易忘事吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionXueyu, QuestionText: "您口唇颜色偏暗吗?", Options: optStr, OrderNum: 7}, + + // 气郁质 (7题) + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您感到闷闷不乐、情绪低沉吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您精神紧张、焦虑不安吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您多愁善感、感情脆弱吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您容易感到害怕或受到惊吓吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您胁肋部或乳房胀痛吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您无缘无故叹气吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionQiyu, QuestionText: "您咽喉部有异物感吗?", Options: optStr, OrderNum: 7}, + + // 特禀质 (7题) + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会打喷嚏吗?", Options: optStr, OrderNum: 1}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您没有感冒时也会鼻塞、流鼻涕吗?", Options: optStr, OrderNum: 2}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您有因季节变化、温度变化或异味引起的咳嗽吗?", Options: optStr, OrderNum: 3}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您容易过敏吗?", Options: optStr, OrderNum: 4}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤容易起荨麻疹吗?", Options: optStr, OrderNum: 5}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤一抓就红,并出现抓痕吗?", Options: optStr, OrderNum: 6}, + {ConstitutionType: model.ConstitutionTebing, QuestionText: "您皮肤或身上容易出现紫红色瘀点、瘀斑吗?", Options: optStr, OrderNum: 7}, + } +} diff --git a/server/internal/model/constitution.go b/server/internal/model/constitution.go new file mode 100644 index 0000000..4dc8b39 --- /dev/null +++ b/server/internal/model/constitution.go @@ -0,0 +1,145 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// ConstitutionAssessment 体质测评记录 +type ConstitutionAssessment struct { + gorm.Model + UserID uint `gorm:"index" json:"user_id"` + AssessedAt time.Time `json:"assessed_at"` + Scores string `gorm:"type:text" json:"scores"` // JSON: 各体质得分 + PrimaryConstitution string `gorm:"size:20" json:"primary_constitution"` // 主要体质 + SecondaryConstitutions string `gorm:"type:text" json:"secondary_constitutions"` // JSON: 次要体质 + Recommendations string `gorm:"type:text" json:"recommendations"` // JSON: 调养建议 +} + +// AssessmentAnswer 问卷答案 +type AssessmentAnswer struct { + gorm.Model + AssessmentID uint `gorm:"index" json:"assessment_id"` + QuestionID uint `json:"question_id"` + Score int `json:"score"` // 1-5 +} + +// QuestionBank 问卷题库 +type QuestionBank struct { + gorm.Model + ConstitutionType string `gorm:"size:20;index" json:"constitution_type"` // 体质类型 + QuestionText string `gorm:"type:text" json:"question_text"` + Options string `gorm:"type:text" json:"options"` // JSON: 选项 + OrderNum int `json:"order_num"` +} + +// ConstitutionType 体质类型常量 +const ( + ConstitutionPinghe = "pinghe" // 平和质 + ConstitutionQixu = "qixu" // 气虚质 + ConstitutionYangxu = "yangxu" // 阳虚质 + ConstitutionYinxu = "yinxu" // 阴虚质 + ConstitutionTanshi = "tanshi" // 痰湿质 + ConstitutionShire = "shire" // 湿热质 + ConstitutionXueyu = "xueyu" // 血瘀质 + ConstitutionQiyu = "qiyu" // 气郁质 + ConstitutionTebing = "tebing" // 特禀质 +) + +// ConstitutionNames 体质名称映射 +var ConstitutionNames = map[string]string{ + ConstitutionPinghe: "平和质", + ConstitutionQixu: "气虚质", + ConstitutionYangxu: "阳虚质", + ConstitutionYinxu: "阴虚质", + ConstitutionTanshi: "痰湿质", + ConstitutionShire: "湿热质", + ConstitutionXueyu: "血瘀质", + ConstitutionQiyu: "气郁质", + ConstitutionTebing: "特禀质", +} + +// ConstitutionDescriptions 体质特征描述 +var ConstitutionDescriptions = map[string]string{ + ConstitutionPinghe: "阴阳气血调和,体态适中,面色红润,精力充沛", + ConstitutionQixu: "元气不足,容易疲劳,气短懒言,易出汗", + ConstitutionYangxu: "阳气不足,畏寒怕冷,手脚冰凉,喜热饮", + ConstitutionYinxu: "阴液亏少,口燥咽干,手足心热,盗汗", + ConstitutionTanshi: "痰湿凝聚,形体肥胖,腹部肥满,痰多", + ConstitutionShire: "湿热内蕴,面垢油光,口苦口干,大便黏滞", + ConstitutionXueyu: "血行不畅,肤色晦暗,易生斑点,健忘", + ConstitutionQiyu: "气机郁滞,情绪低落,多愁善感,胸闷", + ConstitutionTebing: "先天失常,过敏体质,易打喷嚏,皮肤易过敏", +} + +// ConstitutionRecommendations 体质调养建议 +var ConstitutionRecommendations = map[string]map[string]string{ + ConstitutionPinghe: { + "diet": "饮食均衡,不偏食,粗细搭配", + "lifestyle": "起居有常,劳逸结合", + "exercise": "可进行各种运动,量力而行", + "emotion": "保持乐观积极的心态", + }, + ConstitutionQixu: { + "diet": "宜食益气健脾食物,如山药、大枣、小米", + "lifestyle": "避免劳累,保证充足睡眠", + "exercise": "宜柔和运动,如太极拳、散步", + "emotion": "避免过度思虑", + }, + ConstitutionYangxu: { + "diet": "宜食温阳食物,如羊肉、韭菜、生姜", + "lifestyle": "注意保暖,避免受寒", + "exercise": "宜温和运动,避免大汗", + "emotion": "保持积极乐观", + }, + ConstitutionYinxu: { + "diet": "宜食滋阴食物,如百合、银耳、枸杞", + "lifestyle": "避免熬夜,保持环境湿润", + "exercise": "宜静养,避免剧烈运动", + "emotion": "避免急躁易怒", + }, + ConstitutionTanshi: { + "diet": "饮食清淡,少食肥甘厚味,宜食薏米、冬瓜", + "lifestyle": "居住环境宜干燥通风", + "exercise": "坚持运动,促进代谢", + "emotion": "保持心情舒畅", + }, + ConstitutionShire: { + "diet": "饮食清淡,宜食苦瓜、绿豆、薏米", + "lifestyle": "避免湿热环境,保持皮肤清洁", + "exercise": "适当运动,出汗排湿", + "emotion": "保持平和心态", + }, + ConstitutionXueyu: { + "diet": "宜食活血化瘀食物,如山楂、黑木耳", + "lifestyle": "避免久坐,适当活动", + "exercise": "坚持有氧运动,促进血液循环", + "emotion": "保持心情愉快", + }, + ConstitutionQiyu: { + "diet": "宜食行气解郁食物,如玫瑰花、佛手", + "lifestyle": "多参加社交活动", + "exercise": "宜户外运动,舒展身心", + "emotion": "学会疏导情绪,培养兴趣爱好", + }, + ConstitutionTebing: { + "diet": "避免食用过敏食物,饮食清淡", + "lifestyle": "避免接触过敏原,保持环境清洁", + "exercise": "适度运动,增强体质", + "emotion": "保持心态平和", + }, +} + +// TableName 指定表名 +func (ConstitutionAssessment) TableName() string { + return "constitution_assessments" +} + +func (AssessmentAnswer) TableName() string { + return "assessment_answers" +} + +func (QuestionBank) TableName() string { + return "question_banks" +} diff --git a/server/internal/model/conversation.go b/server/internal/model/conversation.go new file mode 100644 index 0000000..d9e54f5 --- /dev/null +++ b/server/internal/model/conversation.go @@ -0,0 +1,35 @@ +package model + +import "gorm.io/gorm" + +// Conversation 对话 +type Conversation struct { + gorm.Model + UserID uint `gorm:"index" json:"user_id"` + Title string `gorm:"size:200" json:"title"` + Messages []Message `gorm:"foreignKey:ConversationID" json:"messages,omitempty"` +} + +// Message 消息 +type Message struct { + gorm.Model + ConversationID uint `gorm:"index" json:"conversation_id"` + Role string `gorm:"size:20" json:"role"` // user, assistant, system + Content string `gorm:"type:text" json:"content"` +} + +// MessageRole 消息角色常量 +const ( + RoleUser = "user" + RoleAssistant = "assistant" + RoleSystem = "system" +) + +// TableName 指定表名 +func (Conversation) TableName() string { + return "conversations" +} + +func (Message) TableName() string { + return "messages" +} diff --git a/server/internal/model/health.go b/server/internal/model/health.go new file mode 100644 index 0000000..ff0c1fc --- /dev/null +++ b/server/internal/model/health.go @@ -0,0 +1,46 @@ +package model + +import "gorm.io/gorm" + +// MedicalHistory 既往病史 +type MedicalHistory struct { + gorm.Model + HealthProfileID uint `gorm:"index" json:"health_profile_id"` + DiseaseName string `gorm:"size:100" json:"disease_name"` + DiseaseType string `gorm:"size:50" json:"disease_type"` // chronic, surgery, other + DiagnosedDate string `gorm:"size:20" json:"diagnosed_date"` + Status string `gorm:"size:20" json:"status"` // cured, treating, controlled + Notes string `gorm:"type:text" json:"notes"` +} + +// FamilyHistory 家族病史 +type FamilyHistory struct { + gorm.Model + HealthProfileID uint `gorm:"index" json:"health_profile_id"` + Relation string `gorm:"size:20" json:"relation"` // father, mother, grandparent + DiseaseName string `gorm:"size:100" json:"disease_name"` + Notes string `gorm:"type:text" json:"notes"` +} + +// AllergyRecord 过敏记录 +type AllergyRecord struct { + gorm.Model + HealthProfileID uint `gorm:"index" json:"health_profile_id"` + AllergyType string `gorm:"size:20" json:"allergy_type"` // drug, food, other + Allergen string `gorm:"size:100" json:"allergen"` + Severity string `gorm:"size:20" json:"severity"` // mild, moderate, severe + ReactionDesc string `gorm:"type:text" json:"reaction_desc"` +} + +// TableName 指定表名 +func (MedicalHistory) TableName() string { + return "medical_histories" +} + +func (FamilyHistory) TableName() string { + return "family_histories" +} + +func (AllergyRecord) TableName() string { + return "allergy_records" +} diff --git a/server/internal/model/models.go b/server/internal/model/models.go new file mode 100644 index 0000000..7fa652f --- /dev/null +++ b/server/internal/model/models.go @@ -0,0 +1,31 @@ +package model + +// AllModels 返回所有需要迁移的模型 +func AllModels() []interface{} { + return []interface{}{ + // 用户相关 + &User{}, + &HealthProfile{}, + &LifestyleInfo{}, + + // 健康相关 + &MedicalHistory{}, + &FamilyHistory{}, + &AllergyRecord{}, + + // 体质相关 + &ConstitutionAssessment{}, + &AssessmentAnswer{}, + &QuestionBank{}, + + // 对话相关 + &Conversation{}, + &Message{}, + + // 产品相关 + &Product{}, + &ConstitutionProduct{}, + &SymptomProduct{}, + &PurchaseHistory{}, + } +} diff --git a/server/internal/model/product.go b/server/internal/model/product.go new file mode 100644 index 0000000..894d53c --- /dev/null +++ b/server/internal/model/product.go @@ -0,0 +1,66 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// Product 保健品表 +type Product struct { + gorm.Model + Name string `gorm:"size:100" json:"name"` + Category string `gorm:"size:50;index" json:"category"` + Description string `gorm:"type:text" json:"description"` + Efficacy string `gorm:"type:text" json:"efficacy"` // 功效说明 + Suitable string `gorm:"type:text" json:"suitable"` // 适用人群/体质 + Price float64 `json:"price"` + ImageURL string `gorm:"size:255" json:"image_url"` + MallURL string `gorm:"size:255" json:"mall_url"` // 商城链接 + IsActive bool `gorm:"default:true" json:"is_active"` +} + +// ConstitutionProduct 体质-产品关联表 +type ConstitutionProduct struct { + gorm.Model + ConstitutionType string `gorm:"size:20;index" json:"constitution_type"` + ProductID uint `gorm:"index" json:"product_id"` + Priority int `json:"priority"` // 推荐优先级 + Reason string `gorm:"size:200" json:"reason"` // 推荐理由 +} + +// SymptomProduct 症状-产品关联表 +type SymptomProduct struct { + gorm.Model + Keyword string `gorm:"size:50;index" json:"keyword"` // 症状关键词 + ProductID uint `gorm:"index" json:"product_id"` + Priority int `json:"priority"` +} + +// PurchaseHistory 购买历史表(商城同步) +type PurchaseHistory struct { + gorm.Model + UserID uint `gorm:"index" json:"user_id"` + OrderNo string `gorm:"size:50" json:"order_no"` // 商城订单号 + ProductID uint `json:"product_id"` + ProductName string `gorm:"size:100" json:"product_name"` + PurchasedAt time.Time `json:"purchased_at"` + Source string `gorm:"size:20" json:"source"` // mall=保健品商城 +} + +// TableName 指定表名 +func (Product) TableName() string { + return "products" +} + +func (ConstitutionProduct) TableName() string { + return "constitution_products" +} + +func (SymptomProduct) TableName() string { + return "symptom_products" +} + +func (PurchaseHistory) TableName() string { + return "purchase_histories" +} diff --git a/server/internal/model/user.go b/server/internal/model/user.go new file mode 100644 index 0000000..d1ec0fd --- /dev/null +++ b/server/internal/model/user.go @@ -0,0 +1,64 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// User 用户表 +type User struct { + gorm.Model + Phone string `gorm:"uniqueIndex;size:20" json:"phone"` + Email string `gorm:"uniqueIndex;size:100" json:"email"` + PasswordHash string `gorm:"size:255" json:"-"` + Nickname string `gorm:"size:50" json:"nickname"` + Avatar string `gorm:"size:255" json:"avatar"` + SurveyCompleted bool `gorm:"default:false" json:"survey_completed"` +} + +// HealthProfile 健康档案 +type HealthProfile struct { + gorm.Model + UserID uint `gorm:"uniqueIndex" json:"user_id"` + Name string `gorm:"size:50" json:"name"` + BirthDate *time.Time `json:"birth_date"` + Gender string `gorm:"size:10" json:"gender"` // male, female + Height float64 `json:"height"` // cm + Weight float64 `json:"weight"` // kg + BMI float64 `json:"bmi"` + BloodType string `gorm:"size:10" json:"blood_type"` // A, B, AB, O + Occupation string `gorm:"size:50" json:"occupation"` + MaritalStatus string `gorm:"size:20" json:"marital_status"` // single, married, divorced + Region string `gorm:"size:100" json:"region"` +} + +// LifestyleInfo 生活习惯 +type LifestyleInfo struct { + gorm.Model + UserID uint `gorm:"uniqueIndex" json:"user_id"` + SleepTime string `gorm:"size:10" json:"sleep_time"` // HH:MM + WakeTime string `gorm:"size:10" json:"wake_time"` // HH:MM + SleepQuality string `gorm:"size:20" json:"sleep_quality"` // good, normal, poor + MealRegularity string `gorm:"size:20" json:"meal_regularity"` // regular, irregular + DietPreference string `gorm:"size:50" json:"diet_preference"` // 偏好 + DailyWaterML int `json:"daily_water_ml"` // 每日饮水量 ml + ExerciseFrequency string `gorm:"size:20" json:"exercise_frequency"` // never, sometimes, often, daily + ExerciseType string `gorm:"size:100" json:"exercise_type"` + ExerciseDurationMin int `json:"exercise_duration_min"` // 每次运动时长 + IsSmoker bool `json:"is_smoker"` + AlcoholFrequency string `gorm:"size:20" json:"alcohol_frequency"` // never, sometimes, often +} + +// TableName 指定表名 +func (User) TableName() string { + return "users" +} + +func (HealthProfile) TableName() string { + return "health_profiles" +} + +func (LifestyleInfo) TableName() string { + return "lifestyle_infos" +} diff --git a/server/internal/repository/impl/constitution.go b/server/internal/repository/impl/constitution.go new file mode 100644 index 0000000..a99bddc --- /dev/null +++ b/server/internal/repository/impl/constitution.go @@ -0,0 +1,71 @@ +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type ConstitutionRepository struct{} + +func NewConstitutionRepository() *ConstitutionRepository { + return &ConstitutionRepository{} +} + +// GetQuestions 获取所有问卷题目 +func (r *ConstitutionRepository) GetQuestions() ([]model.QuestionBank, error) { + var questions []model.QuestionBank + err := database.DB.Order("constitution_type, order_num").Find(&questions).Error + return questions, err +} + +// GetQuestionsByType 获取指定体质类型的问题 +func (r *ConstitutionRepository) GetQuestionsByType(constitutionType string) ([]model.QuestionBank, error) { + var questions []model.QuestionBank + err := database.DB.Where("constitution_type = ?", constitutionType).Order("order_num").Find(&questions).Error + return questions, err +} + +// CreateAssessment 创建体质测评记录 +func (r *ConstitutionRepository) CreateAssessment(assessment *model.ConstitutionAssessment) error { + return database.DB.Create(assessment).Error +} + +// GetLatestAssessment 获取用户最新的体质测评结果 +func (r *ConstitutionRepository) GetLatestAssessment(userID uint) (*model.ConstitutionAssessment, error) { + var assessment model.ConstitutionAssessment + err := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC").First(&assessment).Error + return &assessment, err +} + +// GetAssessmentHistory 获取用户的体质测评历史 +func (r *ConstitutionRepository) GetAssessmentHistory(userID uint, limit int) ([]model.ConstitutionAssessment, error) { + var assessments []model.ConstitutionAssessment + query := database.DB.Where("user_id = ?", userID).Order("assessed_at DESC") + if limit > 0 { + query = query.Limit(limit) + } + err := query.Find(&assessments).Error + return assessments, err +} + +// GetAssessmentByID 根据ID获取测评记录 +func (r *ConstitutionRepository) GetAssessmentByID(id uint) (*model.ConstitutionAssessment, error) { + var assessment model.ConstitutionAssessment + err := database.DB.First(&assessment, id).Error + return &assessment, err +} + +// CreateAnswers 批量创建问卷答案 +func (r *ConstitutionRepository) CreateAnswers(answers []model.AssessmentAnswer) error { + if len(answers) == 0 { + return nil + } + return database.DB.Create(&answers).Error +} + +// GetAnswersByAssessmentID 获取测评的所有答案 +func (r *ConstitutionRepository) GetAnswersByAssessmentID(assessmentID uint) ([]model.AssessmentAnswer, error) { + var answers []model.AssessmentAnswer + err := database.DB.Where("assessment_id = ?", assessmentID).Find(&answers).Error + return answers, err +} diff --git a/server/internal/repository/impl/conversation.go b/server/internal/repository/impl/conversation.go new file mode 100644 index 0000000..8d2940d --- /dev/null +++ b/server/internal/repository/impl/conversation.go @@ -0,0 +1,78 @@ +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type ConversationRepository struct{} + +func NewConversationRepository() *ConversationRepository { + return &ConversationRepository{} +} + +// Create 创建对话 +func (r *ConversationRepository) Create(conv *model.Conversation) error { + return database.DB.Create(conv).Error +} + +// GetByID 根据ID获取对话(含消息) +func (r *ConversationRepository) GetByID(id uint) (*model.Conversation, error) { + var conv model.Conversation + err := database.DB.Preload("Messages").First(&conv, id).Error + return &conv, err +} + +// GetByUserID 获取用户的所有对话 +func (r *ConversationRepository) GetByUserID(userID uint) ([]model.Conversation, error) { + var convs []model.Conversation + err := database.DB.Where("user_id = ?", userID).Order("updated_at DESC").Find(&convs).Error + return convs, err +} + +// Delete 删除对话(同时删除消息) +func (r *ConversationRepository) Delete(id uint) error { + // 先删除消息 + database.DB.Where("conversation_id = ?", id).Delete(&model.Message{}) + return database.DB.Delete(&model.Conversation{}, id).Error +} + +// AddMessage 添加消息 +func (r *ConversationRepository) AddMessage(msg *model.Message) error { + // 同时更新对话的更新时间 + database.DB.Model(&model.Conversation{}).Where("id = ?", msg.ConversationID).Update("updated_at", msg.CreatedAt) + return database.DB.Create(msg).Error +} + +// GetMessages 获取对话的消息 +func (r *ConversationRepository) GetMessages(convID uint) ([]model.Message, error) { + var messages []model.Message + err := database.DB.Where("conversation_id = ?", convID).Order("created_at ASC").Find(&messages).Error + return messages, err +} + +// GetRecentMessages 获取对话最近的N条消息 +func (r *ConversationRepository) GetRecentMessages(convID uint, limit int) ([]model.Message, error) { + var messages []model.Message + err := database.DB.Where("conversation_id = ?", convID).Order("created_at DESC").Limit(limit).Find(&messages).Error + if err != nil { + return nil, err + } + // 反转顺序,使消息按时间正序排列 + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + return messages, nil +} + +// UpdateTitle 更新对话标题 +func (r *ConversationRepository) UpdateTitle(id uint, title string) error { + return database.DB.Model(&model.Conversation{}).Where("id = ?", id).Update("title", title).Error +} + +// CheckOwnership 检查对话是否属于用户 +func (r *ConversationRepository) CheckOwnership(convID, userID uint) bool { + var count int64 + database.DB.Model(&model.Conversation{}).Where("id = ? AND user_id = ?", convID, userID).Count(&count) + return count > 0 +} diff --git a/server/internal/repository/impl/health.go b/server/internal/repository/impl/health.go new file mode 100644 index 0000000..c0c33d5 --- /dev/null +++ b/server/internal/repository/impl/health.go @@ -0,0 +1,127 @@ +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type HealthRepository struct{} + +func NewHealthRepository() *HealthRepository { + return &HealthRepository{} +} + +// ================= HealthProfile ================= + +func (r *HealthRepository) CreateProfile(profile *model.HealthProfile) error { + return database.DB.Create(profile).Error +} + +func (r *HealthRepository) GetProfileByUserID(userID uint) (*model.HealthProfile, error) { + var profile model.HealthProfile + err := database.DB.Where("user_id = ?", userID).First(&profile).Error + return &profile, err +} + +func (r *HealthRepository) UpdateProfile(profile *model.HealthProfile) error { + return database.DB.Save(profile).Error +} + +// ================= LifestyleInfo ================= + +func (r *HealthRepository) CreateLifestyle(lifestyle *model.LifestyleInfo) error { + return database.DB.Create(lifestyle).Error +} + +func (r *HealthRepository) GetLifestyleByUserID(userID uint) (*model.LifestyleInfo, error) { + var lifestyle model.LifestyleInfo + err := database.DB.Where("user_id = ?", userID).First(&lifestyle).Error + return &lifestyle, err +} + +func (r *HealthRepository) UpdateLifestyle(lifestyle *model.LifestyleInfo) error { + return database.DB.Save(lifestyle).Error +} + +// ================= MedicalHistory ================= + +func (r *HealthRepository) CreateMedicalHistory(history *model.MedicalHistory) error { + return database.DB.Create(history).Error +} + +func (r *HealthRepository) GetMedicalHistories(profileID uint) ([]model.MedicalHistory, error) { + var histories []model.MedicalHistory + err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error + return histories, err +} + +func (r *HealthRepository) DeleteMedicalHistory(id uint) error { + return database.DB.Delete(&model.MedicalHistory{}, id).Error +} + +func (r *HealthRepository) BatchCreateMedicalHistories(histories []model.MedicalHistory) error { + if len(histories) == 0 { + return nil + } + return database.DB.Create(&histories).Error +} + +// ================= FamilyHistory ================= + +func (r *HealthRepository) CreateFamilyHistory(history *model.FamilyHistory) error { + return database.DB.Create(history).Error +} + +func (r *HealthRepository) GetFamilyHistories(profileID uint) ([]model.FamilyHistory, error) { + var histories []model.FamilyHistory + err := database.DB.Where("health_profile_id = ?", profileID).Find(&histories).Error + return histories, err +} + +func (r *HealthRepository) DeleteFamilyHistory(id uint) error { + return database.DB.Delete(&model.FamilyHistory{}, id).Error +} + +func (r *HealthRepository) BatchCreateFamilyHistories(histories []model.FamilyHistory) error { + if len(histories) == 0 { + return nil + } + return database.DB.Create(&histories).Error +} + +// ================= AllergyRecord ================= + +func (r *HealthRepository) CreateAllergyRecord(record *model.AllergyRecord) error { + return database.DB.Create(record).Error +} + +func (r *HealthRepository) GetAllergyRecords(profileID uint) ([]model.AllergyRecord, error) { + var records []model.AllergyRecord + err := database.DB.Where("health_profile_id = ?", profileID).Find(&records).Error + return records, err +} + +func (r *HealthRepository) DeleteAllergyRecord(id uint) error { + return database.DB.Delete(&model.AllergyRecord{}, id).Error +} + +func (r *HealthRepository) BatchCreateAllergyRecords(records []model.AllergyRecord) error { + if len(records) == 0 { + return nil + } + return database.DB.Create(&records).Error +} + +// ================= 清除旧数据方法 ================= + +func (r *HealthRepository) ClearMedicalHistories(profileID uint) error { + return database.DB.Where("health_profile_id = ?", profileID).Delete(&model.MedicalHistory{}).Error +} + +func (r *HealthRepository) ClearFamilyHistories(profileID uint) error { + return database.DB.Where("health_profile_id = ?", profileID).Delete(&model.FamilyHistory{}).Error +} + +func (r *HealthRepository) ClearAllergyRecords(profileID uint) error { + return database.DB.Where("health_profile_id = ?", profileID).Delete(&model.AllergyRecord{}).Error +} diff --git a/server/internal/repository/impl/product.go b/server/internal/repository/impl/product.go new file mode 100644 index 0000000..2600e5a --- /dev/null +++ b/server/internal/repository/impl/product.go @@ -0,0 +1,99 @@ +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type ProductRepository struct{} + +func NewProductRepository() *ProductRepository { + return &ProductRepository{} +} + +// GetByID 根据ID获取产品 +func (r *ProductRepository) GetByID(id uint) (*model.Product, error) { + var product model.Product + err := database.DB.Where("is_active = ?", true).First(&product, id).Error + return &product, err +} + +// GetAll 获取所有产品(分页) +func (r *ProductRepository) GetAll(page, pageSize int) ([]model.Product, int64, error) { + var products []model.Product + var total int64 + + db := database.DB.Model(&model.Product{}).Where("is_active = ?", true) + db.Count(&total) + + offset := (page - 1) * pageSize + err := db.Offset(offset).Limit(pageSize).Find(&products).Error + return products, total, err +} + +// GetByCategory 按分类获取产品 +func (r *ProductRepository) GetByCategory(category string) ([]model.Product, error) { + var products []model.Product + err := database.DB.Where("category = ? AND is_active = ?", category, true).Find(&products).Error + return products, err +} + +// GetByConstitution 根据体质获取推荐产品 +func (r *ProductRepository) GetByConstitution(constitutionType string) ([]model.Product, error) { + var products []model.Product + err := database.DB.Joins("JOIN constitution_products ON products.id = constitution_products.product_id"). + Where("constitution_products.constitution_type = ? AND products.is_active = ?", constitutionType, true). + Order("constitution_products.priority ASC"). + Find(&products).Error + return products, err +} + +// SearchByKeyword 根据症状关键词搜索产品 +func (r *ProductRepository) SearchByKeyword(keyword string) ([]model.Product, error) { + var products []model.Product + + // 先从症状-产品关联表搜索 + subQuery := database.DB.Table("symptom_products"). + Select("product_id"). + Where("keyword LIKE ?", "%"+keyword+"%") + + err := database.DB.Where("id IN (?) AND is_active = ?", subQuery, true).Find(&products).Error + if err != nil { + return nil, err + } + + // 如果没找到,从产品名称和描述中搜索 + if len(products) == 0 { + err = database.DB.Where("(name LIKE ? OR description LIKE ? OR efficacy LIKE ?) AND is_active = ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", true).Find(&products).Error + } + + return products, err +} + +// GetConstitutionProducts 获取体质-产品关联 +func (r *ProductRepository) GetConstitutionProducts(constitutionType string) ([]model.ConstitutionProduct, error) { + var cps []model.ConstitutionProduct + err := database.DB.Where("constitution_type = ?", constitutionType).Order("priority ASC").Find(&cps).Error + return cps, err +} + +// CreatePurchaseHistory 创建购买历史 +func (r *ProductRepository) CreatePurchaseHistory(history *model.PurchaseHistory) error { + return database.DB.Create(history).Error +} + +// GetPurchaseHistory 获取用户购买历史 +func (r *ProductRepository) GetPurchaseHistory(userID uint) ([]model.PurchaseHistory, error) { + var histories []model.PurchaseHistory + err := database.DB.Where("user_id = ?", userID).Order("purchased_at DESC").Find(&histories).Error + return histories, err +} + +// BatchCreatePurchaseHistory 批量创建购买历史 +func (r *ProductRepository) BatchCreatePurchaseHistory(histories []model.PurchaseHistory) error { + if len(histories) == 0 { + return nil + } + return database.DB.Create(&histories).Error +} diff --git a/server/internal/repository/impl/user.go b/server/internal/repository/impl/user.go new file mode 100644 index 0000000..2e4a812 --- /dev/null +++ b/server/internal/repository/impl/user.go @@ -0,0 +1,42 @@ +package impl + +import ( + "health-ai/internal/database" + "health-ai/internal/model" +) + +type UserRepositoryImpl struct{} + +func NewUserRepository() *UserRepositoryImpl { + return &UserRepositoryImpl{} +} + +func (r *UserRepositoryImpl) Create(user *model.User) error { + return database.DB.Create(user).Error +} + +func (r *UserRepositoryImpl) GetByID(id uint) (*model.User, error) { + var user model.User + err := database.DB.First(&user, id).Error + return &user, err +} + +func (r *UserRepositoryImpl) GetByPhone(phone string) (*model.User, error) { + var user model.User + err := database.DB.Where("phone = ?", phone).First(&user).Error + return &user, err +} + +func (r *UserRepositoryImpl) GetByEmail(email string) (*model.User, error) { + var user model.User + err := database.DB.Where("email = ?", email).First(&user).Error + return &user, err +} + +func (r *UserRepositoryImpl) Update(user *model.User) error { + return database.DB.Save(user).Error +} + +func (r *UserRepositoryImpl) UpdateSurveyStatus(userID uint, completed bool) error { + return database.DB.Model(&model.User{}).Where("id = ?", userID).Update("survey_completed", completed).Error +} diff --git a/server/internal/repository/interface.go b/server/internal/repository/interface.go new file mode 100644 index 0000000..2ccb8c9 --- /dev/null +++ b/server/internal/repository/interface.go @@ -0,0 +1,66 @@ +package repository + +import "health-ai/internal/model" + +// UserRepository 用户数据访问接口 +type UserRepository interface { + Create(user *model.User) error + GetByID(id uint) (*model.User, error) + GetByPhone(phone string) (*model.User, error) + GetByEmail(email string) (*model.User, error) + Update(user *model.User) error + UpdateSurveyStatus(userID uint, completed bool) error +} + +// HealthProfileRepository 健康档案数据访问接口 +type HealthProfileRepository interface { + Create(profile *model.HealthProfile) error + GetByUserID(userID uint) (*model.HealthProfile, error) + Update(profile *model.HealthProfile) error + Upsert(profile *model.HealthProfile) error +} + +// LifestyleRepository 生活习惯数据访问接口 +type LifestyleRepository interface { + Create(lifestyle *model.LifestyleInfo) error + GetByUserID(userID uint) (*model.LifestyleInfo, error) + Update(lifestyle *model.LifestyleInfo) error + Upsert(lifestyle *model.LifestyleInfo) error +} + +// MedicalHistoryRepository 病史数据访问接口 +type MedicalHistoryRepository interface { + Create(history *model.MedicalHistory) error + GetByHealthProfileID(healthProfileID uint) ([]model.MedicalHistory, error) + Update(history *model.MedicalHistory) error + Delete(id uint) error + BatchCreate(histories []model.MedicalHistory) error +} + +// ConstitutionRepository 体质测评数据访问接口 +type ConstitutionRepository interface { + CreateAssessment(assessment *model.ConstitutionAssessment) error + GetLatestByUserID(userID uint) (*model.ConstitutionAssessment, error) + GetHistoryByUserID(userID uint, limit int) ([]model.ConstitutionAssessment, error) + GetQuestions() ([]model.QuestionBank, error) + CreateAnswers(answers []model.AssessmentAnswer) error +} + +// ConversationRepository 对话数据访问接口 +type ConversationRepository interface { + Create(conversation *model.Conversation) error + GetByID(id uint) (*model.Conversation, error) + GetByUserID(userID uint) ([]model.Conversation, error) + Delete(id uint) error + AddMessage(message *model.Message) error + GetMessages(conversationID uint, limit int) ([]model.Message, error) +} + +// ProductRepository 产品数据访问接口 +type ProductRepository interface { + GetByID(id uint) (*model.Product, error) + GetByCategory(category string) ([]model.Product, error) + GetByConstitution(constitutionType string) ([]model.Product, error) + SearchByKeyword(keyword string) ([]model.Product, error) + GetAll(page, pageSize int) ([]model.Product, int64, error) +} diff --git a/server/internal/service/ai/aliyun.go b/server/internal/service/ai/aliyun.go new file mode 100644 index 0000000..16bad6d --- /dev/null +++ b/server/internal/service/ai/aliyun.go @@ -0,0 +1,165 @@ +package ai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +const AliyunBaseURL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation" + +type AliyunClient struct { + apiKey string + model string +} + +func NewAliyunClient(cfg *Config) *AliyunClient { + model := cfg.Model + if model == "" { + model = "qwen-turbo" + } + return &AliyunClient{ + apiKey: cfg.APIKey, + model: model, + } +} + +type aliyunRequest struct { + Model string `json:"model"` + Input struct { + Messages []Message `json:"messages"` + } `json:"input"` + Parameters struct { + ResultFormat string `json:"result_format"` + MaxTokens int `json:"max_tokens,omitempty"` + } `json:"parameters"` +} + +type aliyunResponse struct { + Output struct { + Text string `json:"text"` + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } `json:"output"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (c *AliyunClient) Chat(ctx context.Context, messages []Message) (string, error) { + if c.apiKey == "" { + return "", fmt.Errorf("阿里云通义千问 API Key 未配置,请在 config.yaml 中设置 ai.aliyun.api_key") + } + + reqBody := aliyunRequest{ + Model: c.model, + } + reqBody.Input.Messages = messages + reqBody.Parameters.ResultFormat = "message" + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("调用AI服务失败: %v", err) + } + defer resp.Body.Close() + + var result aliyunResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("解析AI响应失败: %v", err) + } + + if result.Code != "" { + return "", fmt.Errorf("AI服务错误: %s - %s", result.Code, result.Message) + } + + // 兼容两种返回格式 + if len(result.Output.Choices) > 0 { + return result.Output.Choices[0].Message.Content, nil + } + if result.Output.Text != "" { + return result.Output.Text, nil + } + + return "", fmt.Errorf("AI未返回有效响应") +} + +func (c *AliyunClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error { + if c.apiKey == "" { + return fmt.Errorf("阿里云通义千问 API Key 未配置") + } + + reqBody := aliyunRequest{ + Model: c.model, + } + reqBody.Input.Messages = messages + reqBody.Parameters.ResultFormat = "message" + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", AliyunBaseURL, bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("X-DashScope-SSE", "enable") // 启用流式输出 + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // 解析 SSE 流 + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + if err == io.EOF { + break + } + if err != nil { + return err + } + + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "data:") { + data := strings.TrimPrefix(line, "data:") + data = strings.TrimSpace(data) + if data == "[DONE]" { + break + } + + var streamResp aliyunResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + + if len(streamResp.Output.Choices) > 0 { + content := streamResp.Output.Choices[0].Message.Content + writer.Write([]byte(content)) + } + } + } + + return nil +} diff --git a/server/internal/service/ai/client.go b/server/internal/service/ai/client.go new file mode 100644 index 0000000..0b90beb --- /dev/null +++ b/server/internal/service/ai/client.go @@ -0,0 +1,26 @@ +package ai + +import ( + "context" + "io" +) + +// AIClient AI 客户端接口 +type AIClient interface { + Chat(ctx context.Context, messages []Message) (string, error) + ChatStream(ctx context.Context, messages []Message, writer io.Writer) error +} + +// Message 对话消息 +type Message struct { + Role string `json:"role"` // system, user, assistant + Content string `json:"content"` +} + +// Config AI 配置 +type Config struct { + Provider string + APIKey string + BaseURL string + Model string +} diff --git a/server/internal/service/ai/factory.go b/server/internal/service/ai/factory.go new file mode 100644 index 0000000..16e62d4 --- /dev/null +++ b/server/internal/service/ai/factory.go @@ -0,0 +1,22 @@ +package ai + +import "health-ai/internal/config" + +// NewAIClient 根据配置创建 AI 客户端 +func NewAIClient(cfg *config.AIConfig) AIClient { + switch cfg.Provider { + case "aliyun": + return NewAliyunClient(&Config{ + APIKey: cfg.Aliyun.APIKey, + Model: cfg.Aliyun.Model, + }) + case "openai": + fallthrough + default: + return NewOpenAIClient(&Config{ + APIKey: cfg.OpenAI.APIKey, + BaseURL: cfg.OpenAI.BaseURL, + Model: cfg.OpenAI.Model, + }) + } +} diff --git a/server/internal/service/ai/openai.go b/server/internal/service/ai/openai.go new file mode 100644 index 0000000..e6d8d6f --- /dev/null +++ b/server/internal/service/ai/openai.go @@ -0,0 +1,156 @@ +package ai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type OpenAIClient struct { + apiKey string + baseURL string + model string +} + +func NewOpenAIClient(cfg *Config) *OpenAIClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + model := cfg.Model + if model == "" { + model = "gpt-3.5-turbo" + } + return &OpenAIClient{ + apiKey: cfg.APIKey, + baseURL: baseURL, + model: model, + } +} + +type openAIRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` +} + +type openAIResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (c *OpenAIClient) Chat(ctx context.Context, messages []Message) (string, error) { + if c.apiKey == "" { + return "", fmt.Errorf("OpenAI API Key 未配置,请在 config.yaml 中设置 ai.openai.api_key") + } + + reqBody := openAIRequest{ + Model: c.model, + Messages: messages, + Stream: false, + } + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("调用AI服务失败: %v", err) + } + defer resp.Body.Close() + + var result openAIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("解析AI响应失败: %v", err) + } + + if result.Error != nil { + return "", fmt.Errorf("AI服务错误: %s", result.Error.Message) + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("AI未返回有效响应") + } + + return result.Choices[0].Message.Content, nil +} + +func (c *OpenAIClient) ChatStream(ctx context.Context, messages []Message, writer io.Writer) error { + if c.apiKey == "" { + return fmt.Errorf("OpenAI API Key 未配置") + } + + reqBody := openAIRequest{ + Model: c.model, + Messages: messages, + Stream: true, + } + + body, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // 解析 SSE 流 + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + if err == io.EOF { + break + } + if err != nil { + return err + } + + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "data:") { + data := strings.TrimPrefix(line, "data:") + data = strings.TrimSpace(data) + if data == "[DONE]" { + break + } + + var streamResp openAIResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + + if len(streamResp.Choices) > 0 { + content := streamResp.Choices[0].Delta.Content + writer.Write([]byte(content)) + } + } + } + + return nil +} diff --git a/server/internal/service/auth.go b/server/internal/service/auth.go new file mode 100644 index 0000000..dd6f778 --- /dev/null +++ b/server/internal/service/auth.go @@ -0,0 +1,161 @@ +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(), + } +} + +// RegisterRequest 注册请求 +type RegisterRequest struct { + Phone string `json:"phone" binding:"required"` + Password string `json:"password" binding:"required,min=6"` + Nickname string `json:"nickname"` +} + +// LoginRequest 登录请求 +type LoginRequest struct { + Phone string `json:"phone" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// AuthResponse 认证响应 +type AuthResponse struct { + Token string `json:"token"` + UserID uint `json:"user_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + SurveyCompleted bool `json:"survey_completed"` +} + +// UserInfoResponse 用户信息响应 +type UserInfoResponse struct { + UserID uint `json:"user_id"` + Phone string `json:"phone"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + SurveyCompleted bool `json:"survey_completed"` +} + +// Register 用户注册 +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, errors.New("密码加密失败") + } + + // 创建用户 + user := &model.User{ + Phone: req.Phone, + PasswordHash: string(hash), + Nickname: req.Nickname, + } + if user.Nickname == "" { + // 默认昵称:手机号后4位 + user.Nickname = "用户" + req.Phone[len(req.Phone)-4:] + } + + if err := s.userRepo.Create(user); err != nil { + return nil, errors.New("创建用户失败") + } + + // 生成 Token + token, err := jwt.GenerateToken(user.ID) + if err != nil { + return nil, errors.New("生成Token失败") + } + + return &AuthResponse{ + Token: token, + UserID: user.ID, + Nickname: user.Nickname, + Avatar: user.Avatar, + SurveyCompleted: user.SurveyCompleted, + }, nil +} + +// Login 用户登录 +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, errors.New("生成Token失败") + } + + return &AuthResponse{ + Token: token, + UserID: user.ID, + Nickname: user.Nickname, + Avatar: user.Avatar, + SurveyCompleted: user.SurveyCompleted, + }, nil +} + +// GetUserInfo 获取用户信息 +func (s *AuthService) GetUserInfo(userID uint) (*UserInfoResponse, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, errors.New("用户不存在") + } + + return &UserInfoResponse{ + UserID: user.ID, + Phone: user.Phone, + Email: user.Email, + Nickname: user.Nickname, + Avatar: user.Avatar, + SurveyCompleted: user.SurveyCompleted, + }, nil +} + +// RefreshToken 刷新Token +func (s *AuthService) RefreshToken(oldToken string) (string, error) { + return jwt.RefreshToken(oldToken) +} + +// UpdateProfile 更新用户资料 +func (s *AuthService) UpdateProfile(userID uint, nickname, avatar string) error { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return errors.New("用户不存在") + } + + if nickname != "" { + user.Nickname = nickname + } + if avatar != "" { + user.Avatar = avatar + } + + return s.userRepo.Update(user) +} diff --git a/server/internal/service/constitution.go b/server/internal/service/constitution.go new file mode 100644 index 0000000..0e022a9 --- /dev/null +++ b/server/internal/service/constitution.go @@ -0,0 +1,326 @@ +package service + +import ( + "encoding/json" + "errors" + "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(), + } +} + +// ================= 请求结构体 ================= + +// AnswerRequest 单个答案请求 +type AnswerRequest struct { + QuestionID uint `json:"question_id" binding:"required"` + Score int `json:"score" binding:"required,min=1,max=5"` +} + +// SubmitAssessmentRequest 提交测评请求 +type SubmitAssessmentRequest struct { + Answers []AnswerRequest `json:"answers" binding:"required,dive"` +} + +// ================= 响应结构体 ================= + +// ConstitutionScore 体质得分 +type ConstitutionScore struct { + Type string `json:"type"` + Name string `json:"name"` + Score float64 `json:"score"` + Description string `json:"description"` +} + +// AssessmentResult 测评结果 +type AssessmentResult struct { + ID uint `json:"id"` + 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"` +} + +// QuestionGroup 问题分组 +type QuestionGroup struct { + ConstitutionType string `json:"constitution_type"` + ConstitutionName string `json:"constitution_name"` + Questions []model.QuestionBank `json:"questions"` +} + +// ================= Service 方法 ================= + +// GetQuestions 获取所有问卷题目 +func (s *ConstitutionService) GetQuestions() ([]model.QuestionBank, error) { + return s.repo.GetQuestions() +} + +// GetQuestionsGrouped 获取分组的问卷题目 +func (s *ConstitutionService) GetQuestionsGrouped() ([]QuestionGroup, error) { + questions, err := s.repo.GetQuestions() + if err != nil { + return nil, err + } + + // 按体质类型分组 + groupMap := make(map[string][]model.QuestionBank) + for _, q := range questions { + groupMap[q.ConstitutionType] = append(groupMap[q.ConstitutionType], q) + } + + // 转换为数组 + var groups []QuestionGroup + typeOrder := []string{ + model.ConstitutionPinghe, + model.ConstitutionQixu, + model.ConstitutionYangxu, + model.ConstitutionYinxu, + model.ConstitutionTanshi, + model.ConstitutionShire, + model.ConstitutionXueyu, + model.ConstitutionQiyu, + model.ConstitutionTebing, + } + + for _, cType := range typeOrder { + if qs, ok := groupMap[cType]; ok { + groups = append(groups, QuestionGroup{ + ConstitutionType: cType, + ConstitutionName: model.ConstitutionNames[cType], + Questions: qs, + }) + } + } + + return groups, nil +} + +// SubmitAssessment 提交测评并计算结果 +func (s *ConstitutionService) SubmitAssessment(userID uint, req *SubmitAssessmentRequest) (*AssessmentResult, error) { + // 获取所有问题 + questions, err := s.repo.GetQuestions() + if err != nil { + return nil, err + } + + if len(questions) == 0 { + return nil, errors.New("问卷题库为空,请联系管理员") + } + + // 构建问题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 + } + } + + // 计算转化分 + // 转化分 = (原始分 - 条目数) / (条目数 × 4) × 100 + allScores := make([]ConstitutionScore, 0) + for cType, rawScore := range typeScores { + questionCount := typeQuestionCount[cType] + if questionCount == 0 { + continue + } + transformedScore := float64(rawScore-questionCount) / float64(questionCount*4) * 100 + if transformedScore < 0 { + transformedScore = 0 + } + if transformedScore > 100 { + transformedScore = 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 == "" { + for _, score := range allScores { + if score.Type != model.ConstitutionPinghe { + primary = score + break + } + } + } + } + + // 获取调养建议 + recommendations := make(map[string]map[string]string) + if primary.Type != "" { + 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 + } + + // 保存答案记录 + answers := make([]model.AssessmentAnswer, len(req.Answers)) + for i, a := range req.Answers { + answers[i] = model.AssessmentAnswer{ + AssessmentID: assessment.ID, + QuestionID: a.QuestionID, + Score: a.Score, + } + } + if err := s.repo.CreateAnswers(answers); err != nil { + // 答案保存失败不影响结果返回 + } + + return &AssessmentResult{ + ID: assessment.ID, + PrimaryConstitution: primary, + SecondaryConstitutions: secondary, + AllScores: allScores, + Recommendations: recommendations, + AssessedAt: assessment.AssessedAt, + }, nil +} + +// GetLatestResult 获取用户最新的测评结果 +func (s *ConstitutionService) GetLatestResult(userID uint) (*AssessmentResult, error) { + assessment, err := s.repo.GetLatestAssessment(userID) + if err != nil { + return nil, errors.New("暂无体质测评记录") + } + + return s.parseAssessment(assessment) +} + +// GetHistory 获取用户的测评历史 +func (s *ConstitutionService) GetHistory(userID uint, limit int) ([]AssessmentResult, error) { + assessments, err := s.repo.GetAssessmentHistory(userID, limit) + if err != nil { + return nil, err + } + + results := make([]AssessmentResult, 0, len(assessments)) + for _, a := range assessments { + result, err := s.parseAssessment(&a) + if err != nil { + continue + } + results = append(results, *result) + } + + return results, nil +} + +// GetRecommendations 获取体质调养建议 +func (s *ConstitutionService) GetRecommendations(userID uint) (map[string]map[string]string, error) { + result, err := s.GetLatestResult(userID) + if err != nil { + return nil, err + } + return result.Recommendations, nil +} + +// parseAssessment 解析测评记录为结果 +func (s *ConstitutionService) parseAssessment(assessment *model.ConstitutionAssessment) (*AssessmentResult, error) { + 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{ + ID: assessment.ID, + PrimaryConstitution: primary, + SecondaryConstitutions: secondary, + AllScores: allScores, + Recommendations: recommendations, + AssessedAt: assessment.AssessedAt, + }, nil +} diff --git a/server/internal/service/conversation.go b/server/internal/service/conversation.go new file mode 100644 index 0000000..3988af2 --- /dev/null +++ b/server/internal/service/conversation.go @@ -0,0 +1,329 @@ +package service + +import ( + "context" + "errors" + "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 { + return &ConversationService{ + convRepo: impl.NewConversationRepository(), + constitutionRepo: impl.NewConstitutionRepository(), + healthRepo: impl.NewHealthRepository(), + aiClient: ai.NewAIClient(&config.AppConfig.AI), + } +} + +// ================= 请求/响应结构体 ================= + +// CreateConversationRequest 创建对话请求 +type CreateConversationRequest struct { + Title string `json:"title"` +} + +// SendMessageRequest 发送消息请求 +type SendMessageRequest struct { + Content string `json:"content" binding:"required"` +} + +// MessageResponse 消息响应 +type MessageResponse struct { + ID uint `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +// ConversationResponse 对话响应 +type ConversationResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Messages []MessageResponse `json:"messages,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ================= Service 方法 ================= + +// GetConversations 获取用户对话列表 +func (s *ConversationService) GetConversations(userID uint) ([]ConversationResponse, error) { + convs, err := s.convRepo.GetByUserID(userID) + if err != nil { + return nil, err + } + + result := make([]ConversationResponse, len(convs)) + for i, c := range convs { + result[i] = ConversationResponse{ + ID: c.ID, + Title: c.Title, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + } + return result, nil +} + +// CreateConversation 创建新对话 +func (s *ConversationService) CreateConversation(userID uint, title string) (*ConversationResponse, 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 &ConversationResponse{ + ID: conv.ID, + Title: conv.Title, + CreatedAt: conv.CreatedAt, + UpdatedAt: conv.UpdatedAt, + }, nil +} + +// GetConversation 获取对话详情 +func (s *ConversationService) GetConversation(userID, convID uint) (*ConversationResponse, error) { + // 检查权限 + if !s.convRepo.CheckOwnership(convID, userID) { + return nil, errors.New("对话不存在或无权访问") + } + + conv, err := s.convRepo.GetByID(convID) + if err != nil { + return nil, errors.New("对话不存在") + } + + messages := make([]MessageResponse, len(conv.Messages)) + for i, m := range conv.Messages { + messages[i] = MessageResponse{ + ID: m.ID, + Role: m.Role, + Content: m.Content, + CreatedAt: m.CreatedAt, + } + } + + return &ConversationResponse{ + ID: conv.ID, + Title: conv.Title, + Messages: messages, + CreatedAt: conv.CreatedAt, + UpdatedAt: conv.UpdatedAt, + }, nil +} + +// DeleteConversation 删除对话 +func (s *ConversationService) DeleteConversation(userID, convID uint) error { + // 检查权限 + if !s.convRepo.CheckOwnership(convID, userID) { + return errors.New("对话不存在或无权访问") + } + return s.convRepo.Delete(convID) +} + +// SendMessage 发送消息并获取AI回复 +func (s *ConversationService) SendMessage(ctx context.Context, userID, convID uint, content string) (*MessageResponse, error) { + // 检查权限 + if !s.convRepo.CheckOwnership(convID, userID) { + return nil, errors.New("对话不存在或无权访问") + } + + // 保存用户消息 + userMsg := &model.Message{ + ConversationID: convID, + Role: model.RoleUser, + Content: content, + } + if err := s.convRepo.AddMessage(userMsg); err != nil { + return nil, errors.New("保存消息失败") + } + + // 构建对话上下文 + messages := s.buildMessages(userID, convID) + + // 调用 AI + response, err := s.aiClient.Chat(ctx, messages) + if err != nil { + return nil, err + } + + // 保存 AI 回复 + assistantMsg := &model.Message{ + ConversationID: convID, + Role: model.RoleAssistant, + Content: response, + } + if err := s.convRepo.AddMessage(assistantMsg); err != nil { + return nil, errors.New("保存AI回复失败") + } + + // 如果是第一条消息,更新对话标题 + messages_count, _ := s.convRepo.GetMessages(convID) + if len(messages_count) <= 2 { + // 使用用户消息的前20个字符作为标题 + title := content + if len(title) > 20 { + title = title[:20] + "..." + } + s.convRepo.UpdateTitle(convID, title) + } + + return &MessageResponse{ + ID: assistantMsg.ID, + Role: assistantMsg.Role, + Content: assistantMsg.Content, + CreatedAt: assistantMsg.CreatedAt, + }, nil +} + +// SendMessageStream 流式发送消息 +func (s *ConversationService) SendMessageStream(ctx context.Context, userID, convID uint, content string, writer io.Writer) error { + // 检查权限 + if !s.convRepo.CheckOwnership(convID, userID) { + return errors.New("对话不存在或无权访问") + } + + // 保存用户消息 + userMsg := &model.Message{ + ConversationID: convID, + Role: model.RoleUser, + Content: content, + } + if err := s.convRepo.AddMessage(userMsg); err != nil { + return errors.New("保存消息失败") + } + + // 构建对话上下文 + messages := s.buildMessages(userID, convID) + + // 调用 AI 流式接口 + return s.aiClient.ChatStream(ctx, messages, writer) +} + +// buildMessages 构建消息上下文 +func (s *ConversationService) buildMessages(userID, convID uint) []ai.Message { + messages := []ai.Message{} + + // 系统提示词 + systemPrompt := s.buildSystemPrompt(userID) + messages = append(messages, ai.Message{ + Role: "system", + Content: systemPrompt, + }) + + // 历史消息(限制数量避免超出 token 限制) + maxHistory := config.AppConfig.AI.MaxHistoryMessages + if maxHistory <= 0 { + maxHistory = 10 // 默认10条 + } + + historyMsgs, _ := s.convRepo.GetRecentMessages(convID, maxHistory) + for _, msg := range historyMsgs { + messages = append(messages, ai.Message{ + Role: msg.Role, + Content: msg.Content, + }) + } + + return messages +} + +// 系统提示词模板 +const systemPromptTemplate = `# 角色定义 +你是"健康AI助手",一个专业的健康咨询助理。你基于中医体质辨识理论,为用户提供个性化的健康建议。 + +## 重要声明 +- 你不是专业医师,仅提供健康咨询和养生建议 +- 你的建议不能替代医生的诊断和治疗 +- 遇到以下情况,必须立即建议用户就医: + * 胸痛、呼吸困难、剧烈头痛 + * 高烧不退(超过39°C持续24小时) + * 意识模糊、晕厥 + * 严重外伤、大量出血 + * 持续剧烈腹痛 + * 疑似中风症状(口眼歪斜、肢体无力、言语不清) + +## 用户信息 +%s + +## 用户体质 +%s + +## 回答原则 +1. 回答控制在200字以内,简洁明了 +2. 根据用户体质给出针对性建议 +3. 用药建议优先推荐非处方中成药或食疗,注明"建议咨询药师" +4. 不推荐处方药,不做疾病诊断 +5. 对于紧急情况,立即建议用户就医 + +## 回答格式 +【情况分析】一句话概括 +【建议】 +1. 具体建议1 +2. 具体建议2 +【用药参考】(如适用) +- 药品名称:用法(建议咨询药师) +【提醒】注意事项或就医建议` + +// buildSystemPrompt 构建系统提示词(包含用户体质信息) +func (s *ConversationService) buildSystemPrompt(userID uint) string { + var userProfile, constitutionInfo 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 = "暂未进行体质测评" + } + + return fmt.Sprintf(systemPromptTemplate, userProfile, constitutionInfo) +} + +// calculateAge 计算年龄 +func calculateAge(birthDate *time.Time) int { + if birthDate == nil { + return 0 + } + now := time.Now() + age := now.Year() - birthDate.Year() + if now.YearDay() < birthDate.YearDay() { + age-- + } + return age +} diff --git a/server/internal/service/health.go b/server/internal/service/health.go new file mode 100644 index 0000000..f4dbfa5 --- /dev/null +++ b/server/internal/service/health.go @@ -0,0 +1,135 @@ +package service + +import ( + "errors" + + "health-ai/internal/model" + "health-ai/internal/repository/impl" +) + +type HealthService struct { + healthRepo *impl.HealthRepository + constitutionRepo *impl.ConstitutionRepository + userRepo *impl.UserRepositoryImpl +} + +func NewHealthService() *HealthService { + return &HealthService{ + healthRepo: impl.NewHealthRepository(), + constitutionRepo: impl.NewConstitutionRepository(), + userRepo: impl.NewUserRepository(), + } +} + +// ================= 响应结构体 ================= + +// HealthProfileResponse 健康档案响应 +type HealthProfileResponse struct { + Profile *model.HealthProfile `json:"profile"` + Lifestyle *model.LifestyleInfo `json:"lifestyle"` + MedicalHistory []model.MedicalHistory `json:"medical_history"` + FamilyHistory []model.FamilyHistory `json:"family_history"` + AllergyRecords []model.AllergyRecord `json:"allergy_records"` + Constitution *ConstitutionScore `json:"constitution,omitempty"` +} + +// ================= Service 方法 ================= + +// GetHealthProfile 获取完整健康档案 +func (s *HealthService) GetHealthProfile(userID uint) (*HealthProfileResponse, error) { + resp := &HealthProfileResponse{} + + // 获取基础档案 + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err == nil && profile.ID > 0 { + resp.Profile = profile + } + + // 获取生活习惯 + lifestyle, err := s.healthRepo.GetLifestyleByUserID(userID) + if err == nil && lifestyle.ID > 0 { + resp.Lifestyle = lifestyle + } + + // 获取病史 + if profile != nil && profile.ID > 0 { + resp.MedicalHistory, _ = s.healthRepo.GetMedicalHistories(profile.ID) + resp.FamilyHistory, _ = s.healthRepo.GetFamilyHistories(profile.ID) + resp.AllergyRecords, _ = s.healthRepo.GetAllergyRecords(profile.ID) + } + + // 获取最新体质信息 + constitution, err := s.constitutionRepo.GetLatestAssessment(userID) + if err == nil && constitution.ID > 0 { + resp.Constitution = &ConstitutionScore{ + Type: constitution.PrimaryConstitution, + Name: model.ConstitutionNames[constitution.PrimaryConstitution], + Description: model.ConstitutionDescriptions[constitution.PrimaryConstitution], + } + } + + return resp, nil +} + +// GetBasicProfile 获取基础档案 +func (s *HealthService) GetBasicProfile(userID uint) (*model.HealthProfile, error) { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return nil, errors.New("健康档案不存在") + } + return profile, nil +} + +// GetLifestyle 获取生活习惯 +func (s *HealthService) GetLifestyle(userID uint) (*model.LifestyleInfo, error) { + lifestyle, err := s.healthRepo.GetLifestyleByUserID(userID) + if err != nil { + return nil, errors.New("生活习惯信息不存在") + } + return lifestyle, nil +} + +// GetMedicalHistory 获取病史列表 +func (s *HealthService) GetMedicalHistory(userID uint) ([]model.MedicalHistory, error) { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return nil, errors.New("请先填写基础信息") + } + return s.healthRepo.GetMedicalHistories(profile.ID) +} + +// GetFamilyHistory 获取家族病史列表 +func (s *HealthService) GetFamilyHistory(userID uint) ([]model.FamilyHistory, error) { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return nil, errors.New("请先填写基础信息") + } + return s.healthRepo.GetFamilyHistories(profile.ID) +} + +// GetAllergyRecords 获取过敏记录列表 +func (s *HealthService) GetAllergyRecords(userID uint) ([]model.AllergyRecord, error) { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return nil, errors.New("请先填写基础信息") + } + return s.healthRepo.GetAllergyRecords(profile.ID) +} + +// DeleteMedicalHistory 删除病史记录 +func (s *HealthService) DeleteMedicalHistory(userID, historyID uint) error { + // TODO: 验证记录属于该用户 + return s.healthRepo.DeleteMedicalHistory(historyID) +} + +// DeleteFamilyHistory 删除家族病史记录 +func (s *HealthService) DeleteFamilyHistory(userID, historyID uint) error { + // TODO: 验证记录属于该用户 + return s.healthRepo.DeleteFamilyHistory(historyID) +} + +// DeleteAllergyRecord 删除过敏记录 +func (s *HealthService) DeleteAllergyRecord(userID, recordID uint) error { + // TODO: 验证记录属于该用户 + return s.healthRepo.DeleteAllergyRecord(recordID) +} diff --git a/server/internal/service/product.go b/server/internal/service/product.go new file mode 100644 index 0000000..6097320 --- /dev/null +++ b/server/internal/service/product.go @@ -0,0 +1,143 @@ +package service + +import ( + "errors" + "time" + + "health-ai/internal/model" + "health-ai/internal/repository/impl" +) + +type ProductService struct { + productRepo *impl.ProductRepository + constitutionRepo *impl.ConstitutionRepository +} + +func NewProductService() *ProductService { + return &ProductService{ + productRepo: impl.NewProductRepository(), + constitutionRepo: impl.NewConstitutionRepository(), + } +} + +// ================= 请求/响应结构体 ================= + +// ProductListResponse 产品列表响应 +type ProductListResponse struct { + Products []model.Product `json:"products"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ProductRecommendResponse 产品推荐响应 +type ProductRecommendResponse struct { + ConstitutionType string `json:"constitution_type"` + ConstitutionName string `json:"constitution_name"` + Products []model.Product `json:"products"` +} + +// PurchaseSyncRequest 购买同步请求 +type PurchaseSyncRequest struct { + UserID uint `json:"user_id" binding:"required"` + OrderNo string `json:"order_no" binding:"required"` + Products []struct { + ID uint `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + } `json:"products" binding:"required"` + CreatedAt time.Time `json:"created_at"` +} + +// ================= Service 方法 ================= + +// GetProducts 获取产品列表 +func (s *ProductService) GetProducts(page, pageSize int) (*ProductListResponse, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + products, total, err := s.productRepo.GetAll(page, pageSize) + if err != nil { + return nil, err + } + + return &ProductListResponse{ + Products: products, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// GetProductByID 获取产品详情 +func (s *ProductService) GetProductByID(id uint) (*model.Product, error) { + product, err := s.productRepo.GetByID(id) + if err != nil { + return nil, errors.New("产品不存在") + } + return product, nil +} + +// GetProductsByCategory 按分类获取产品 +func (s *ProductService) GetProductsByCategory(category string) ([]model.Product, error) { + return s.productRepo.GetByCategory(category) +} + +// GetRecommendedProducts 根据用户体质获取推荐产品 +func (s *ProductService) GetRecommendedProducts(userID uint) (*ProductRecommendResponse, error) { + // 获取用户最新体质 + assessment, err := s.constitutionRepo.GetLatestAssessment(userID) + if err != nil { + return nil, errors.New("请先完成体质测评") + } + + // 获取该体质的推荐产品 + products, err := s.productRepo.GetByConstitution(assessment.PrimaryConstitution) + if err != nil { + return nil, err + } + + return &ProductRecommendResponse{ + ConstitutionType: assessment.PrimaryConstitution, + ConstitutionName: model.ConstitutionNames[assessment.PrimaryConstitution], + Products: products, + }, nil +} + +// SearchProducts 根据关键词搜索产品 +func (s *ProductService) SearchProducts(keyword string) ([]model.Product, error) { + if keyword == "" { + return nil, errors.New("请输入搜索关键词") + } + return s.productRepo.SearchByKeyword(keyword) +} + +// SyncPurchase 同步商城购买记录 +func (s *ProductService) SyncPurchase(req *PurchaseSyncRequest) error { + purchasedAt := req.CreatedAt + if purchasedAt.IsZero() { + purchasedAt = time.Now() + } + + histories := make([]model.PurchaseHistory, len(req.Products)) + for i, p := range req.Products { + histories[i] = model.PurchaseHistory{ + UserID: req.UserID, + OrderNo: req.OrderNo, + ProductID: p.ID, + ProductName: p.Name, + PurchasedAt: purchasedAt, + Source: "mall", + } + } + + return s.productRepo.BatchCreatePurchaseHistory(histories) +} + +// GetPurchaseHistory 获取用户购买历史 +func (s *ProductService) GetPurchaseHistory(userID uint) ([]model.PurchaseHistory, error) { + return s.productRepo.GetPurchaseHistory(userID) +} diff --git a/server/internal/service/survey.go b/server/internal/service/survey.go new file mode 100644 index 0000000..86e9d11 --- /dev/null +++ b/server/internal/service/survey.go @@ -0,0 +1,368 @@ +package service + +import ( + "errors" + "time" + + "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(), + } +} + +// ================= 请求结构体 ================= + +// BasicInfoRequest 基础信息请求 +type BasicInfoRequest struct { + Name string `json:"name" binding:"required"` + BirthDate string `json:"birth_date"` // 格式: 2006-01-02 + Gender string `json:"gender" binding:"required,oneof=male female"` + Height float64 `json:"height" binding:"required,gt=0"` + Weight float64 `json:"weight" binding:"required,gt=0"` + BloodType string `json:"blood_type"` + Occupation string `json:"occupation"` + MaritalStatus string `json:"marital_status"` + Region string `json:"region"` +} + +// LifestyleRequest 生活习惯请求 +type LifestyleRequest struct { + SleepTime string `json:"sleep_time"` // 格式: HH:MM + WakeTime string `json:"wake_time"` // 格式: HH:MM + SleepQuality string `json:"sleep_quality"` // good, normal, poor + MealRegularity string `json:"meal_regularity"` // regular, irregular + DietPreference string `json:"diet_preference"` + DailyWaterML int `json:"daily_water_ml"` + ExerciseFrequency string `json:"exercise_frequency"` // never, sometimes, often, daily + ExerciseType string `json:"exercise_type"` + ExerciseDurationMin int `json:"exercise_duration_min"` + IsSmoker bool `json:"is_smoker"` + AlcoholFrequency string `json:"alcohol_frequency"` // never, sometimes, often +} + +// MedicalHistoryRequest 病史请求 +type MedicalHistoryRequest struct { + DiseaseName string `json:"disease_name" binding:"required"` + DiseaseType string `json:"disease_type"` // chronic, surgery, other + DiagnosedDate string `json:"diagnosed_date"` + Status string `json:"status"` // cured, treating, controlled + Notes string `json:"notes"` +} + +// FamilyHistoryRequest 家族病史请求 +type FamilyHistoryRequest struct { + Relation string `json:"relation" binding:"required"` // father, mother, grandparent + DiseaseName string `json:"disease_name" binding:"required"` + Notes string `json:"notes"` +} + +// AllergyRequest 过敏信息请求 +type AllergyRequest struct { + AllergyType string `json:"allergy_type" binding:"required"` // drug, food, other + Allergen string `json:"allergen" binding:"required"` + Severity string `json:"severity"` // mild, moderate, severe + ReactionDesc string `json:"reaction_desc"` +} + +// BatchMedicalHistoryRequest 批量病史请求 +type BatchMedicalHistoryRequest struct { + Histories []MedicalHistoryRequest `json:"histories"` +} + +// BatchFamilyHistoryRequest 批量家族病史请求 +type BatchFamilyHistoryRequest struct { + Histories []FamilyHistoryRequest `json:"histories"` +} + +// BatchAllergyRequest 批量过敏信息请求 +type BatchAllergyRequest struct { + Allergies []AllergyRequest `json:"allergies"` +} + +// ================= 响应结构体 ================= + +// SurveyStatusResponse 调查状态响应 +type SurveyStatusResponse struct { + BasicInfo bool `json:"basic_info"` + Lifestyle bool `json:"lifestyle"` + MedicalHistory bool `json:"medical_history"` + FamilyHistory bool `json:"family_history"` + Allergy bool `json:"allergy"` + AllCompleted bool `json:"all_completed"` +} + +// ================= Service 方法 ================= + +// GetStatus 获取调查完成状态 +func (s *SurveyService) GetStatus(userID uint) (*SurveyStatusResponse, error) { + status := &SurveyStatusResponse{ + BasicInfo: false, + Lifestyle: false, + MedicalHistory: false, + FamilyHistory: false, + Allergy: false, + AllCompleted: false, + } + + // 检查基础信息 + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err == nil && profile.ID > 0 { + status.BasicInfo = true + // 病史、家族史、过敏史可以为空,只要有profile就算完成 + status.MedicalHistory = true + status.FamilyHistory = true + status.Allergy = true + } + + // 检查生活习惯 + lifestyle, err := s.healthRepo.GetLifestyleByUserID(userID) + if err == nil && lifestyle.ID > 0 { + status.Lifestyle = true + } + + // 检查是否全部完成 + status.AllCompleted = status.BasicInfo && status.Lifestyle + + return status, nil +} + +// SubmitBasicInfo 提交基础信息 +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, + } + + // 解析出生日期 + if req.BirthDate != "" { + birthDate, err := time.Parse("2006-01-02", req.BirthDate) + if err == nil { + profile.BirthDate = &birthDate + } + } + + // 检查是否已存在 + existing, _ := s.healthRepo.GetProfileByUserID(userID) + if existing.ID > 0 { + profile.ID = existing.ID + profile.CreatedAt = existing.CreatedAt + return s.healthRepo.UpdateProfile(profile) + } + + return s.healthRepo.CreateProfile(profile) +} + +// SubmitLifestyle 提交生活习惯 +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 + lifestyle.CreatedAt = existing.CreatedAt + return s.healthRepo.UpdateLifestyle(lifestyle) + } + + return s.healthRepo.CreateLifestyle(lifestyle) +} + +// SubmitMedicalHistory 提交单条病史 +func (s *SurveyService) SubmitMedicalHistory(userID uint, req *MedicalHistoryRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return errors.New("请先填写基础信息") + } + + 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) +} + +// SubmitBatchMedicalHistory 批量提交病史(覆盖式) +func (s *SurveyService) SubmitBatchMedicalHistory(userID uint, req *BatchMedicalHistoryRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return errors.New("请先填写基础信息") + } + + // 清除旧数据 + if err := s.healthRepo.ClearMedicalHistories(profile.ID); err != nil { + return err + } + + // 创建新数据 + if len(req.Histories) == 0 { + return nil + } + + histories := make([]model.MedicalHistory, len(req.Histories)) + for i, h := range req.Histories { + histories[i] = model.MedicalHistory{ + HealthProfileID: profile.ID, + DiseaseName: h.DiseaseName, + DiseaseType: h.DiseaseType, + DiagnosedDate: h.DiagnosedDate, + Status: h.Status, + Notes: h.Notes, + } + } + + return s.healthRepo.BatchCreateMedicalHistories(histories) +} + +// SubmitFamilyHistory 提交单条家族病史 +func (s *SurveyService) SubmitFamilyHistory(userID uint, req *FamilyHistoryRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return errors.New("请先填写基础信息") + } + + history := &model.FamilyHistory{ + HealthProfileID: profile.ID, + Relation: req.Relation, + DiseaseName: req.DiseaseName, + Notes: req.Notes, + } + + return s.healthRepo.CreateFamilyHistory(history) +} + +// SubmitBatchFamilyHistory 批量提交家族病史(覆盖式) +func (s *SurveyService) SubmitBatchFamilyHistory(userID uint, req *BatchFamilyHistoryRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return errors.New("请先填写基础信息") + } + + // 清除旧数据 + if err := s.healthRepo.ClearFamilyHistories(profile.ID); err != nil { + return err + } + + // 创建新数据 + if len(req.Histories) == 0 { + return nil + } + + histories := make([]model.FamilyHistory, len(req.Histories)) + for i, h := range req.Histories { + histories[i] = model.FamilyHistory{ + HealthProfileID: profile.ID, + Relation: h.Relation, + DiseaseName: h.DiseaseName, + Notes: h.Notes, + } + } + + return s.healthRepo.BatchCreateFamilyHistories(histories) +} + +// SubmitAllergy 提交单条过敏信息 +func (s *SurveyService) SubmitAllergy(userID uint, req *AllergyRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return errors.New("请先填写基础信息") + } + + record := &model.AllergyRecord{ + HealthProfileID: profile.ID, + AllergyType: req.AllergyType, + Allergen: req.Allergen, + Severity: req.Severity, + ReactionDesc: req.ReactionDesc, + } + + return s.healthRepo.CreateAllergyRecord(record) +} + +// SubmitBatchAllergy 批量提交过敏信息(覆盖式) +func (s *SurveyService) SubmitBatchAllergy(userID uint, req *BatchAllergyRequest) error { + profile, err := s.healthRepo.GetProfileByUserID(userID) + if err != nil { + return errors.New("请先填写基础信息") + } + + // 清除旧数据 + if err := s.healthRepo.ClearAllergyRecords(profile.ID); err != nil { + return err + } + + // 创建新数据 + if len(req.Allergies) == 0 { + return nil + } + + records := make([]model.AllergyRecord, len(req.Allergies)) + for i, a := range req.Allergies { + records[i] = model.AllergyRecord{ + HealthProfileID: profile.ID, + AllergyType: a.AllergyType, + Allergen: a.Allergen, + Severity: a.Severity, + ReactionDesc: a.ReactionDesc, + } + } + + return s.healthRepo.BatchCreateAllergyRecords(records) +} + +// MarkSurveyCompleted 标记调查完成 +func (s *SurveyService) MarkSurveyCompleted(userID uint) error { + return s.userRepo.UpdateSurveyStatus(userID, true) +} + +// CompleteSurvey 完成调查(检查并标记) +func (s *SurveyService) CompleteSurvey(userID uint) error { + status, err := s.GetStatus(userID) + if err != nil { + return err + } + + if !status.BasicInfo || !status.Lifestyle { + return errors.New("请先完成基础信息和生活习惯的填写") + } + + return s.MarkSurveyCompleted(userID) +} diff --git a/server/pkg/jwt/jwt.go b/server/pkg/jwt/jwt.go new file mode 100644 index 0000000..4dbc655 --- /dev/null +++ b/server/pkg/jwt/jwt.go @@ -0,0 +1,63 @@ +package jwt + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + jwtSecret []byte + expireHours int +) + +// Init 初始化JWT配置 +func Init(secret string, hours int) { + jwtSecret = []byte(secret) + expireHours = hours +} + +// Claims JWT声明 +type Claims struct { + UserID uint `json:"user_id"` + jwt.RegisteredClaims +} + +// GenerateToken 生成Token +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()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// ParseToken 解析Token +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") +} + +// RefreshToken 刷新Token +func RefreshToken(tokenString string) (string, error) { + claims, err := ParseToken(tokenString) + if err != nil { + return "", err + } + // 生成新Token + return GenerateToken(claims.UserID) +} diff --git a/server/pkg/response/response.go b/server/pkg/response/response.go new file mode 100644 index 0000000..f787532 --- /dev/null +++ b/server/pkg/response/response.go @@ -0,0 +1,80 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 统一响应结构 +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// Success 成功响应 +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: "success", + Data: data, + }) +} + +// SuccessWithMessage 成功响应带消息 +func SuccessWithMessage(c *gin.Context, message string, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: message, + Data: data, + }) +} + +// Error 错误响应 +func Error(c *gin.Context, code int, message string) { + c.JSON(http.StatusOK, Response{ + Code: code, + Message: message, + }) +} + +// BadRequest 参数错误 +func BadRequest(c *gin.Context, message string) { + c.JSON(http.StatusBadRequest, Response{ + Code: 400, + Message: message, + }) +} + +// Unauthorized 未授权 +func Unauthorized(c *gin.Context, message string) { + c.JSON(http.StatusUnauthorized, Response{ + Code: 401, + Message: message, + }) +} + +// Forbidden 禁止访问 +func Forbidden(c *gin.Context, message string) { + c.JSON(http.StatusForbidden, Response{ + Code: 403, + Message: message, + }) +} + +// NotFound 资源不存在 +func NotFound(c *gin.Context, message string) { + c.JSON(http.StatusNotFound, Response{ + Code: 404, + Message: message, + }) +} + +// ServerError 服务器错误 +func ServerError(c *gin.Context, message string) { + c.JSON(http.StatusInternalServerError, Response{ + Code: 500, + Message: message, + }) +} diff --git a/start-app.bat b/start-app.bat new file mode 100644 index 0000000..489bcaf --- /dev/null +++ b/start-app.bat @@ -0,0 +1,26 @@ +@echo off +echo ======================================== +echo Health AI - APP Launcher +echo ======================================== +echo. +echo [1] Web Preview (Browser) +echo [2] Device (Expo Go) +echo [3] Exit +echo. +echo ---------------------------------------- +echo Test Account: 13800138000 / 123456 +echo ---------------------------------------- +echo. +set /p choice=Select option [1-3]: + +cd /d "%~dp0app" + +if "%choice%"=="1" ( + echo Starting Web preview... + npx expo start --web +) else if "%choice%"=="2" ( + echo Starting Expo for device... + npx expo start +) else ( + exit /b 0 +) diff --git a/start-app.sh b/start-app.sh new file mode 100644 index 0000000..639ff21 --- /dev/null +++ b/start-app.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Health AI - APP Launcher (Git Bash / Linux / Mac) + +echo "========================================" +echo " Health AI - APP Launcher" +echo "========================================" +echo "" +echo "Test Account: 13800138000 / 123456" +echo "" +echo "Starting Expo..." +echo "" + +cd "$(dirname "$0")/app" +npx expo start diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..05d9dff --- /dev/null +++ b/start.bat @@ -0,0 +1,47 @@ +@echo off +cls +echo. +echo ======================================== +echo Health AI Assistant - Launcher +echo ======================================== +echo. +echo [1] Start Web (Vue 3) +echo [2] Start APP (React Native) +echo [3] Start Both (Web + APP) +echo [4] Exit +echo. +echo ---------------------------------------- +echo Test Account: 13800138000 / 123456 +echo ---------------------------------------- +echo. +set /p choice=Select option [1-4]: + +if "%choice%"=="1" goto startweb +if "%choice%"=="2" goto startapp +if "%choice%"=="3" goto startall +if "%choice%"=="4" exit /b 0 +goto :eof + +:startweb +echo Starting Web dev server... +cd /d "%~dp0web" +npm run dev +goto :eof + +:startapp +echo Starting APP dev server... +cd /d "%~dp0app" +npx expo start +goto :eof + +:startall +echo Starting both servers... +start cmd /k "cd /d %~dp0web && npm run dev" +timeout /t 2 /nobreak >nul +start cmd /k "cd /d %~dp0app && npx expo start --web" +echo. +echo Servers started in new windows! +echo Web: http://localhost:5173 +echo APP: http://localhost:8081 +pause +goto :eof diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..c3a3298 --- /dev/null +++ b/start.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Health AI Assistant - Launcher for Git Bash / Linux / Mac + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "" +echo "========================================" +echo " Health AI Assistant - Launcher" +echo "========================================" +echo "" +echo " [1] Start Web (Vue 3)" +echo " [2] Start APP (React Native)" +echo " [3] Exit" +echo "" +echo "----------------------------------------" +echo "Test Account: 13800138000 / 123456" +echo "----------------------------------------" +echo "" +read -p "Select option [1-3]: " choice + +case $choice in + 1) + echo "Starting Web dev server..." + cd "$SCRIPT_DIR/web" + npm run dev + ;; + 2) + echo "Starting APP dev server..." + cd "$SCRIPT_DIR/app" + npx expo start + ;; + 3) + exit 0 + ;; + *) + echo "Invalid option" + ;; +esac diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..dfaab4a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3795 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.4", + "dayjs": "^1.11.19", + "echarts": "^6.0.0", + "element-plus": "^2.13.2", + "lodash-es": "^4.17.23", + "pinia": "^3.0.4", + "sass": "^1.97.3", + "vue": "^3.5.24", + "vue-echarts": "^8.0.1", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "dev": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.4.tgz", + "integrity": "sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/element-plus": { + "version": "2.13.2", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.2.tgz", + "integrity": "sha512-Zjzm1NnFXGhV4LYZ6Ze9skPlYi2B4KAmN18FL63A3PZcjhDfroHwhtM6RE8BonlOPHXUnPQynH0BgaoEfvhrGw==", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^10.11.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "devOptional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.0.1.tgz", + "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==", + "peerDependencies": { + "echarts": "^6.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/vue-tsc": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.4.tgz", + "integrity": "sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "dependencies": { + "tslib": "2.3.0" + } + } + }, + "dependencies": { + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" + }, + "@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "requires": { + "@babel/types": "^7.29.0" + } + }, + "@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==" + }, + "@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "requires": {} + }, + "@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "dev": true, + "optional": true + }, + "@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "requires": { + "@floating-ui/utils": "^0.2.10" + } + }, + "@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "requires": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "optional": true, + "requires": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6", + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + } + }, + "@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "optional": true + }, + "@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "optional": true + }, + "@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "optional": true + }, + "@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "optional": true + }, + "@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "optional": true + }, + "@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "optional": true + }, + "@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "optional": true + }, + "@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "optional": true + }, + "@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "optional": true + }, + "@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "optional": true + }, + "@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "optional": true + }, + "@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "optional": true + }, + "@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "optional": true + }, + "@popperjs/core": { + "version": "npm:@sxzz/popperjs-es@2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==" + }, + "@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "dev": true, + "optional": true + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==" + }, + "@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "requires": { + "@types/lodash": "*" + } + }, + "@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "dev": true, + "requires": { + "undici-types": "~7.16.0" + } + }, + "@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" + }, + "@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "requires": { + "@rolldown/pluginutils": "1.0.0-beta.53" + } + }, + "@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "requires": { + "@volar/source-map": "2.4.27" + } + }, + "@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true + }, + "@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "requires": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "requires": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "requires": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "requires": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "requires": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "requires": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "requires": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "requires": { + "rfdc": "^1.4.1" + } + }, + "@vue/language-core": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.4.tgz", + "integrity": "sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==", + "dev": true, + "requires": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "requires": { + "@vue/shared": "3.5.27" + } + }, + "@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "requires": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "requires": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "requires": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==" + }, + "@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "requires": {} + }, + "@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "requires": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "dependencies": { + "vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "requires": {} + } + } + }, + "@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==" + }, + "@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "requires": { + "vue-demi": ">=0.14.8" + }, + "dependencies": { + "vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "requires": {} + } + } + }, + "alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true + }, + "async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==" + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "requires": { + "readdirp": "^4.0.1" + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "requires": { + "is-what": "^5.2.0" + } + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "requires": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "element-plus": { + "version": "2.13.2", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.2.tgz", + "integrity": "sha512-Zjzm1NnFXGhV4LYZ6Ze9skPlYi2B4KAmN18FL63A3PZcjhDfroHwhtM6RE8BonlOPHXUnPQynH0BgaoEfvhrGw==", + "requires": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^10.11.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + } + }, + "entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, + "form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + } + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "optional": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "optional": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==" + }, + "lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==" + }, + "lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "requires": {} + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" + }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "optional": true + }, + "normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==" + }, + "path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "devOptional": true + }, + "pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "requires": { + "@vue/devtools-api": "^7.7.7" + } + }, + "postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" + }, + "rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "sass": { + "version": "1.97.3", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "requires": { + "@parcel/watcher": "^2.4.1", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + } + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==" + }, + "superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "requires": { + "copy-anything": "^4" + } + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + } + }, + "tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true + }, + "undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "vite": { + "version": "7.3.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "requires": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "fsevents": "~2.3.3", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + } + }, + "vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "vue": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "requires": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "vue-echarts": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.0.1.tgz", + "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==", + "requires": {} + }, + "vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "requires": { + "@vue/devtools-api": "^6.6.4" + }, + "dependencies": { + "@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + } + } + }, + "vue-tsc": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.4.tgz", + "integrity": "sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g==", + "dev": true, + "requires": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.4" + } + }, + "zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "requires": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..411c849 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.4", + "dayjs": "^1.11.19", + "echarts": "^6.0.0", + "element-plus": "^2.13.2", + "lodash-es": "^4.17.23", + "pinia": "^3.0.4", + "sass": "^1.97.3", + "vue": "^3.5.24", + "vue-echarts": "^8.0.1", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } +} diff --git a/web/public/vite.svg b/web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..18bcc9d --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,10 @@ + + + diff --git a/web/src/assets/vue.svg b/web/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/web/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/components/HelloWorld.vue b/web/src/components/HelloWorld.vue new file mode 100644 index 0000000..b58e52b --- /dev/null +++ b/web/src/components/HelloWorld.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..5cad474 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,23 @@ +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') diff --git a/web/src/mock/chat.ts b/web/src/mock/chat.ts new file mode 100644 index 0000000..e3e8e63 --- /dev/null +++ b/web/src/mock/chat.ts @@ -0,0 +1,116 @@ +// AI 模拟回复 +const aiReplies: Record = { + '疲劳': `【情况分析】您可能存在气虚的情况,容易感到疲劳乏力。 + +【建议】 +1. 保证充足睡眠,每天7-8小时 +2. 适当运动,如散步、太极拳 +3. 饮食上可多吃山药、大枣、小米等健脾益气的食物 + +【用药参考】 +- 黄芪精口服液:每日2次,每次1支(建议咨询药师) + +【提醒】如果疲劳持续且伴有其他不适,建议就医检查。`, + + '失眠': `【情况分析】失眠可能与心神不宁、肝气郁结有关。 + +【建议】 +1. 睡前1小时避免使用手机 +2. 可用温水泡脚15-20分钟 +3. 睡前喝一杯热牛奶或酸枣仁茶 + +【用药参考】 +- 酸枣仁百合膏:睡前半小时服用(建议咨询药师) + +【提醒】如失眠超过2周,建议就医。`, + + '感冒': `【情况分析】普通感冒多为病毒感染引起。 + +【建议】 +1. 多休息,多喝温水 +2. 饮食清淡,避免油腻 +3. 保持室内通风 + +【用药参考】 +- 感冒清热颗粒:适用于风寒感冒(建议咨询药师) +- 板蓝根颗粒:清热解毒(建议咨询药师) + +【提醒】如发热超过38.5°C持续3天,或出现呼吸困难,请立即就医!`, + + '头痛': `【情况分析】头痛原因较多,需要根据具体情况判断。 + +【建议】 +1. 保证充足睡眠和休息 +2. 避免长时间看屏幕 +3. 适当按摩太阳穴和风池穴 + +【用药参考】 +- 正天丸:适用于紧张性头痛(建议咨询药师) + +【提醒】如果头痛剧烈、突然发作或伴有恶心呕吐、视力模糊,请立即就医!`, + + '血压': `【情况分析】血压管理需要长期坚持。 + +【建议】 +1. 低盐低脂饮食,每日盐摄入<6g +2. 适量运动,如快走、游泳 +3. 保持情绪稳定,避免熬夜 +4. 定期监测血压 + +【用药参考】 +- 深海鱼油软胶囊:辅助调节血脂(建议咨询药师) + +【提醒】高血压患者请遵医嘱用药,不要自行停药或调整剂量。`, + + '便秘': `【情况分析】便秘多与饮食习惯和肠道功能有关。 + +【建议】 +1. 多吃富含纤维的蔬菜水果 +2. 每天喝足8杯水 +3. 养成定时排便习惯 +4. 适当运动促进肠道蠕动 + +【用药参考】 +- 膳食纤维粉:帮助改善便秘(建议咨询药师) + +【提醒】如便秘超过2周或伴有腹痛、便血,请及时就医。`, + + '关节': `【情况分析】关节问题在中老年人群中较为常见。 + +【建议】 +1. 注意关节保暖 +2. 避免长时间站立或负重 +3. 适当进行关节活动,如游泳 +4. 控制体重减轻关节负担 + +【用药参考】 +- 氨糖软骨素钙片:保护关节软骨(建议咨询药师) + +【提醒】如关节疼痛严重或活动受限,建议骨科就诊。` +} + +// 默认回复 +const defaultReply = `【情况分析】感谢您的咨询,我会尽力为您提供健康建议。 + +【建议】 +1. 保持良好的作息习惯 +2. 均衡饮食,适量运动 +3. 保持心情愉悦 + +【提醒】如有任何不适症状加重,请及时就医。我是健康助手,建议仅供参考,不能替代医生的诊断和治疗。` + +// 模拟 AI 回复 +export function mockAIReply(message: string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + // 关键词匹配 + for (const [keyword, reply] of Object.entries(aiReplies)) { + if (message.includes(keyword)) { + resolve(reply) + return + } + } + resolve(defaultReply) + }, 1000 + Math.random() * 1000) + }) +} diff --git a/web/src/mock/constitution.ts b/web/src/mock/constitution.ts new file mode 100644 index 0000000..a263192 --- /dev/null +++ b/web/src/mock/constitution.ts @@ -0,0 +1,221 @@ +import type { ConstitutionQuestion, ConstitutionResult, ConstitutionType } from '@/types' + +// 体质名称映射 +export const constitutionNames: Record = { + pinghe: '平和质', + qixu: '气虚质', + yangxu: '阳虚质', + yinxu: '阴虚质', + tanshi: '痰湿质', + shire: '湿热质', + xueyu: '血瘀质', + qiyu: '气郁质', + tebing: '特禀质' +} + +// 体质详细描述 +export const constitutionDescriptions: Record = { + 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: ['避免接触过敏原', '饮食清淡,避免过敏食物', '适度运动,增强体质', '保持心态平和'] + } +} + +// 问卷选项 +const questionOptions = [ + { value: 1, label: '没有' }, + { value: 2, label: '很少' }, + { value: 3, label: '有时' }, + { value: 4, label: '经常' }, + { value: 5, label: '总是' } +] + +// 体质问卷题目(60题) +export const constitutionQuestions: ConstitutionQuestion[] = [ + // 平和质 (8题) + { id: 1, constitutionType: 'pinghe', question: '您精力充沛吗?', options: questionOptions }, + { id: 2, constitutionType: 'pinghe', question: '您容易疲乏吗?', options: questionOptions }, + { id: 3, constitutionType: 'pinghe', question: '您说话声音低弱无力吗?', options: questionOptions }, + { id: 4, constitutionType: 'pinghe', question: '您感到闷闷不乐、情绪低沉吗?', options: questionOptions }, + { id: 5, constitutionType: 'pinghe', question: '您比一般人耐受不了寒冷吗?', options: questionOptions }, + { id: 6, constitutionType: 'pinghe', question: '您能适应外界自然和社会环境的变化吗?', options: questionOptions }, + { id: 7, constitutionType: 'pinghe', question: '您容易失眠吗?', options: questionOptions }, + + // 气虚质 (8题) + { id: 8, constitutionType: 'qixu', question: '您容易气短(呼吸短促,接不上气)吗?', options: questionOptions }, + { id: 9, constitutionType: 'qixu', question: '您容易心慌吗?', options: questionOptions }, + { id: 10, constitutionType: 'qixu', question: '您容易头晕或站起时晕眩吗?', options: questionOptions }, + { id: 11, constitutionType: 'qixu', question: '您比别人容易感冒吗?', options: questionOptions }, + { id: 12, constitutionType: 'qixu', question: '您喜欢安静、懒得说话吗?', options: questionOptions }, + { id: 13, constitutionType: 'qixu', question: '您说话声音低弱无力吗?', options: questionOptions }, + { id: 14, constitutionType: 'qixu', question: '您活动量稍大就容易出虚汗吗?', options: questionOptions }, + + // 阳虚质 (7题) + { id: 15, constitutionType: 'yangxu', question: '您手脚发凉吗?', options: questionOptions }, + { id: 16, constitutionType: 'yangxu', question: '您胃脘部、背部或腰膝部怕冷吗?', options: questionOptions }, + { id: 17, constitutionType: 'yangxu', question: '您穿的衣服总比别人多吗?', options: questionOptions }, + { id: 18, constitutionType: 'yangxu', question: '您比一般人耐受不了寒冷吗?', options: questionOptions }, + { id: 19, constitutionType: 'yangxu', question: '您比别人容易感冒吗?', options: questionOptions }, + { id: 20, constitutionType: 'yangxu', question: '您吃凉东西会感到不舒服或怕吃凉东西吗?', options: questionOptions }, + { id: 21, constitutionType: 'yangxu', question: '您受凉或吃凉的东西后,容易拉肚子吗?', options: questionOptions }, + + // 阴虚质 (7题) + { id: 22, constitutionType: 'yinxu', question: '您感到手脚心发热吗?', options: questionOptions }, + { id: 23, constitutionType: 'yinxu', question: '您感觉身体、脸上发热吗?', options: questionOptions }, + { id: 24, constitutionType: 'yinxu', question: '您皮肤或口唇干吗?', options: questionOptions }, + { id: 25, constitutionType: 'yinxu', question: '您口唇的颜色比一般人红吗?', options: questionOptions }, + { id: 26, constitutionType: 'yinxu', question: '您容易便秘或大便干燥吗?', options: questionOptions }, + { id: 27, constitutionType: 'yinxu', question: '您感到眼睛干涩吗?', options: questionOptions }, + { id: 28, constitutionType: 'yinxu', question: '您感到口干咽燥、总想喝水吗?', options: questionOptions }, + + // 痰湿质 (7题) + { id: 29, constitutionType: 'tanshi', question: '您感到胸闷或腹部胀满吗?', options: questionOptions }, + { id: 30, constitutionType: 'tanshi', question: '您感到身体沉重不轻松或不爽快吗?', options: questionOptions }, + { id: 31, constitutionType: 'tanshi', question: '您腹部肥满松软吗?', options: questionOptions }, + { id: 32, constitutionType: 'tanshi', question: '您额头部位油脂分泌多吗?', options: questionOptions }, + { id: 33, constitutionType: 'tanshi', question: '您上眼睑比别人肿吗?', options: questionOptions }, + { id: 34, constitutionType: 'tanshi', question: '您嘴里有黏黏的感觉吗?', options: questionOptions }, + { id: 35, constitutionType: 'tanshi', question: '您平时痰多吗?', options: questionOptions }, + + // 湿热质 (7题) + { id: 36, constitutionType: 'shire', question: '您面部或鼻部有油腻感或油光发亮吗?', options: questionOptions }, + { id: 37, constitutionType: 'shire', question: '您脸上容易生痤疮或皮肤容易生疮疖吗?', options: questionOptions }, + { id: 38, constitutionType: 'shire', question: '您感到口苦或嘴里有异味吗?', options: questionOptions }, + { id: 39, constitutionType: 'shire', question: '您大便黏滞不爽、有解不尽的感觉吗?', options: questionOptions }, + { id: 40, constitutionType: 'shire', question: '您小便时尿道有发热感、尿色浓吗?', options: questionOptions }, + { id: 41, constitutionType: 'shire', question: '您带下色黄(白带颜色发黄)吗?(女性)', options: questionOptions }, + { id: 42, constitutionType: 'shire', question: '您的阴囊部位潮湿吗?(男性)', options: questionOptions }, + + // 血瘀质 (7题) + { id: 43, constitutionType: 'xueyu', question: '您皮肤在不知不觉中会出现青紫瘀斑吗?', options: questionOptions }, + { id: 44, constitutionType: 'xueyu', question: '您两颧部有细微红丝吗?', options: questionOptions }, + { id: 45, constitutionType: 'xueyu', question: '您身体上有哪里疼痛吗?', options: questionOptions }, + { id: 46, constitutionType: 'xueyu', question: '您面色晦暗或容易出现褐斑吗?', options: questionOptions }, + { id: 47, constitutionType: 'xueyu', question: '您容易有黑眼圈吗?', options: questionOptions }, + { id: 48, constitutionType: 'xueyu', question: '您容易忘事吗?', options: questionOptions }, + { id: 49, constitutionType: 'xueyu', question: '您口唇颜色偏暗吗?', options: questionOptions }, + + // 气郁质 (7题) + { id: 50, constitutionType: 'qiyu', question: '您感到闷闷不乐、情绪低沉吗?', options: questionOptions }, + { id: 51, constitutionType: 'qiyu', question: '您精神紧张、焦虑不安吗?', options: questionOptions }, + { id: 52, constitutionType: 'qiyu', question: '您多愁善感、感情脆弱吗?', options: questionOptions }, + { id: 53, constitutionType: 'qiyu', question: '您容易感到害怕或受到惊吓吗?', options: questionOptions }, + { id: 54, constitutionType: 'qiyu', question: '您胁肋部或乳房胀痛吗?', options: questionOptions }, + { id: 55, constitutionType: 'qiyu', question: '您无缘无故叹气吗?', options: questionOptions }, + { id: 56, constitutionType: 'qiyu', question: '您咽喉部有异物感吗?', options: questionOptions }, + + // 特禀质 (4题) + { id: 57, constitutionType: 'tebing', question: '您没有感冒时也会打喷嚏吗?', options: questionOptions }, + { id: 58, constitutionType: 'tebing', question: '您没有感冒时也会鼻塞、流鼻涕吗?', options: questionOptions }, + { id: 59, constitutionType: 'tebing', question: '您有因季节变化、温度变化或异味引起的咳嗽吗?', options: questionOptions }, + { id: 60, constitutionType: 'tebing', question: '您容易过敏(药物、食物、气味等)吗?', options: questionOptions } +] + +// 计算体质结果 +export function calculateConstitution(answers: Record): ConstitutionResult { + const types: ConstitutionType[] = ['pinghe', 'qixu', 'yangxu', 'yinxu', 'tanshi', 'shire', 'xueyu', 'qiyu', 'tebing'] + + // 统计各体质得分 + const typeScores: Record = {} as any + types.forEach(type => { + typeScores[type] = { total: 0, count: 0 } + }) + + constitutionQuestions.forEach(q => { + const answer = answers[q.id] || 3 + typeScores[q.constitutionType].total += answer + typeScores[q.constitutionType].count++ + }) + + // 计算转化分 + const scores: Record = {} as any + types.forEach(type => { + const { total, count } = typeScores[type] + if (count > 0) { + scores[type] = Math.round(((total - count) / (count * 4)) * 100) + } else { + scores[type] = 0 + } + }) + + // 确定主体质 + let primaryType: ConstitutionType = 'pinghe' + let maxScore = 0 + + // 平和质特殊判定 + if (scores.pinghe >= 60) { + const otherMax = Math.max(...types.filter(t => t !== 'pinghe').map(t => scores[t])) + if (otherMax < 30) { + primaryType = 'pinghe' + } else { + types.filter(t => t !== 'pinghe').forEach(type => { + if (scores[type] > maxScore) { + maxScore = scores[type] + primaryType = type + } + }) + } + } else { + types.forEach(type => { + if (type !== 'pinghe' && scores[type] > maxScore) { + maxScore = scores[type] + primaryType = type + } + }) + } + + const info = constitutionDescriptions[primaryType] + + return { + primaryType, + scores, + description: info.description, + suggestions: info.suggestions, + assessedAt: new Date().toISOString() + } +} diff --git a/web/src/mock/index.ts b/web/src/mock/index.ts new file mode 100644 index 0000000..4283505 --- /dev/null +++ b/web/src/mock/index.ts @@ -0,0 +1,4 @@ +export * from './user' +export * from './constitution' +export * from './chat' +export * from './products' diff --git a/web/src/mock/products.ts b/web/src/mock/products.ts new file mode 100644 index 0000000..f878083 --- /dev/null +++ b/web/src/mock/products.ts @@ -0,0 +1,69 @@ +import type { Product, ConstitutionType } from '@/types' + +// 模拟产品数据 +export const mockProducts: Product[] = [ + // 补气类 + { id: 1, name: '黄芪精口服液', category: '补气类', description: '补气固表', efficacy: '补气固表,增强免疫力', suitable: '气虚质、易疲劳人群', price: 68, imageUrl: '', mallUrl: 'https://mall.example.com/product/1' }, + { id: 2, name: '人参蜂王浆', category: '补气类', description: '补气养血', efficacy: '补气养血,改善疲劳', suitable: '气虚质、体力不足人群', price: 128, imageUrl: '', mallUrl: 'https://mall.example.com/product/2' }, + { id: 3, name: '西洋参含片', category: '补气类', description: '益气养阴', efficacy: '益气养阴,清热生津', suitable: '气虚质、气阴两虚人群', price: 98, imageUrl: '', mallUrl: 'https://mall.example.com/product/3' }, + + // 温阳类 + { id: 4, name: '鹿茸参精胶囊', category: '温阳类', description: '温肾壮阳', efficacy: '温肾壮阳,补气养血', suitable: '阳虚质、畏寒怕冷人群', price: 268, imageUrl: '', mallUrl: 'https://mall.example.com/product/4' }, + { id: 5, name: '桂圆红枣茶', category: '温阳类', description: '温中补血', efficacy: '温中补血,养心安神', suitable: '阳虚质、手脚冰凉人群', price: 45, imageUrl: '', mallUrl: 'https://mall.example.com/product/5' }, + + // 滋阴类 + { id: 6, name: '枸杞原浆', category: '滋阴类', description: '滋补肝肾', efficacy: '滋补肝肾,明目润肺', suitable: '阴虚质、眼睛干涩人群', price: 158, imageUrl: '', mallUrl: 'https://mall.example.com/product/6' }, + { id: 7, name: '即食燕窝', category: '滋阴类', description: '滋阴润肺', efficacy: '滋阴润肺,美容养颜', suitable: '阴虚质、皮肤干燥人群', price: 398, imageUrl: '', mallUrl: 'https://mall.example.com/product/7' }, + { id: 8, name: '铁皮石斛粉', category: '滋阴类', description: '滋阴清热', efficacy: '滋阴清热,养胃生津', suitable: '阴虚质、口干舌燥人群', price: 188, imageUrl: '', mallUrl: 'https://mall.example.com/product/8' }, + + // 祛湿类 + { id: 9, name: '红豆薏米粉', category: '祛湿类', description: '健脾祛湿', efficacy: '健脾祛湿,消肿利水', suitable: '痰湿质、湿热质', price: 39, imageUrl: '', mallUrl: 'https://mall.example.com/product/9' }, + { id: 10, name: '茯苓山药糕', category: '祛湿类', description: '健脾益气', efficacy: '健脾益气,祛湿止泻', suitable: '痰湿质、脾胃虚弱人群', price: 56, imageUrl: '', mallUrl: 'https://mall.example.com/product/10' }, + { id: 11, name: '清热祛湿茶', category: '祛湿类', description: '清热利湿', efficacy: '清热利湿,解毒消肿', suitable: '湿热质、口苦口干人群', price: 35, imageUrl: '', mallUrl: 'https://mall.example.com/product/11' }, + + // 活血类 + { id: 12, name: '三七粉', category: '活血类', description: '活血化瘀', efficacy: '活血化瘀,消肿止痛', suitable: '血瘀质、面色晦暗人群', price: 128, imageUrl: '', mallUrl: 'https://mall.example.com/product/12' }, + { id: 13, name: '丹参片', category: '活血类', description: '活血通经', efficacy: '活血化瘀,通经止痛', suitable: '血瘀质、易生斑点人群', price: 48, imageUrl: '', mallUrl: 'https://mall.example.com/product/13' }, + + // 理气类 + { id: 14, name: '玫瑰花茶', category: '理气类', description: '疏肝理气', efficacy: '疏肝理气,美容养颜', suitable: '气郁质、情绪低落人群', price: 38, imageUrl: '', mallUrl: 'https://mall.example.com/product/14' }, + { id: 15, name: '陈皮普洱茶', category: '理气类', description: '理气健脾', efficacy: '理气健脾,消食化痰', suitable: '气郁质、胸闷不适人群', price: 68, imageUrl: '', mallUrl: 'https://mall.example.com/product/15' }, + + // 抗敏类 + { id: 16, name: '益生菌粉', category: '抗敏类', description: '调节肠道', efficacy: '调节肠道,增强免疫', suitable: '特禀质、过敏体质人群', price: 98, imageUrl: '', mallUrl: 'https://mall.example.com/product/16' }, + { id: 17, name: '蜂胶软胶囊', category: '抗敏类', description: '抗菌消炎', efficacy: '抗菌消炎,增强体质', suitable: '特禀质、免疫力低下人群', price: 168, imageUrl: '', mallUrl: 'https://mall.example.com/product/17' }, + + // 心脑血管类 + { id: 18, name: '深海鱼油软胶囊', category: '心脑血管类', description: '调节血脂', efficacy: '辅助降血脂,保护心脑血管', suitable: '高血脂、动脉硬化人群', price: 128, imageUrl: '', mallUrl: 'https://mall.example.com/product/18' }, + { id: 19, name: '纳豆激酶胶囊', category: '心脑血管类', description: '溶解血栓', efficacy: '溶解血栓,改善血液循环', suitable: '中老年心脑血管亚健康人群', price: 198, imageUrl: '', mallUrl: 'https://mall.example.com/product/19' }, + + // 骨关节类 + { id: 20, name: '氨糖软骨素钙片', category: '骨关节类', description: '修复软骨', efficacy: '修复软骨,润滑关节,补充钙质', suitable: '关节疼痛、骨质疏松人群', price: 168, imageUrl: '', mallUrl: 'https://mall.example.com/product/20' }, + { id: 21, name: '液体钙维D软胶囊', category: '骨关节类', description: '补钙', efficacy: '补钙,促进钙吸收', suitable: '中老年人、骨质疏松人群', price: 78, imageUrl: '', mallUrl: 'https://mall.example.com/product/21' }, + + // 助眠类 + { id: 22, name: '褪黑素维生素B6片', category: '助眠类', description: '改善睡眠', efficacy: '改善睡眠,调节生物钟', suitable: '失眠、睡眠质量差人群', price: 68, imageUrl: '', mallUrl: 'https://mall.example.com/product/22' }, + { id: 23, name: '酸枣仁百合膏', category: '助眠类', description: '养心安神', efficacy: '养心安神,改善睡眠', suitable: '心烦失眠、多梦易醒人群', price: 58, imageUrl: '', mallUrl: 'https://mall.example.com/product/23' }, + + // 健脑类 + { id: 24, name: '银杏叶提取物片', category: '健脑类', description: '改善记忆', efficacy: '改善记忆力,促进脑部血液循环', suitable: '记忆力减退、脑供血不足人群', price: 98, imageUrl: '', mallUrl: 'https://mall.example.com/product/24' }, +] + +// 体质-产品关联 +const constitutionProductMap: Record = { + pinghe: [1, 2], + qixu: [1, 2, 3], + yangxu: [4, 5], + yinxu: [6, 7, 8], + tanshi: [9, 10], + shire: [9, 11], + xueyu: [12, 13, 18], + qiyu: [14, 15, 23], + tebing: [16, 17] +} + +// 根据体质获取推荐产品 +export function getProductsByConstitution(type: ConstitutionType): Product[] { + const productIds = constitutionProductMap[type] || [] + return mockProducts.filter(p => productIds.includes(p.id)) +} diff --git a/web/src/mock/user.ts b/web/src/mock/user.ts new file mode 100644 index 0000000..e57367c --- /dev/null +++ b/web/src/mock/user.ts @@ -0,0 +1,23 @@ +import type { User } from '@/types' + +// 模拟用户数据 +export const mockUser: User = { + id: 1, + phone: '13800138000', + nickname: '健康达人', + avatar: '', + surveyCompleted: true +} + +// 模拟登录 +export function mockLogin(phone: string, code: string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + if (phone === '13800138000' && code === '123456') { + resolve(mockUser) + } else { + resolve(null) + } + }, 800) + }) +} diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..15153a3 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,76 @@ +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 diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..ff874e9 --- /dev/null +++ b/web/src/stores/auth.ts @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { User } from '@/types' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(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 } +}) diff --git a/web/src/stores/chat.ts b/web/src/stores/chat.ts new file mode 100644 index 0000000..6f5327e --- /dev/null +++ b/web/src/stores/chat.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Conversation, Message } from '@/types' + +export const useChatStore = defineStore('chat', () => { + const conversations = ref([]) + + 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 } +}) diff --git a/web/src/stores/constitution.ts b/web/src/stores/constitution.ts new file mode 100644 index 0000000..ae87924 --- /dev/null +++ b/web/src/stores/constitution.ts @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { ConstitutionResult } from '@/types' + +export const useConstitutionStore = defineStore('constitution', () => { + const result = ref(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 } +}) diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/web/src/styles/index.scss b/web/src/styles/index.scss new file mode 100644 index 0000000..f99cd98 --- /dev/null +++ b/web/src/styles/index.scss @@ -0,0 +1,47 @@ +// 主题色 +$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; +} + +html, body, #app { + height: 100%; +} + +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); +} diff --git a/web/src/types/index.ts b/web/src/types/index.ts new file mode 100644 index 0000000..24c00e7 --- /dev/null +++ b/web/src/types/index.ts @@ -0,0 +1,60 @@ +// 用户类型 +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 + 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 + suitable: string + price: number + imageUrl: string + mallUrl: string +} diff --git a/web/src/views/auth/LoginView.vue b/web/src/views/auth/LoginView.vue new file mode 100644 index 0000000..1b77af8 --- /dev/null +++ b/web/src/views/auth/LoginView.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/web/src/views/chat/ChatDetailView.vue b/web/src/views/chat/ChatDetailView.vue new file mode 100644 index 0000000..fedf8b5 --- /dev/null +++ b/web/src/views/chat/ChatDetailView.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/web/src/views/chat/ChatListView.vue b/web/src/views/chat/ChatListView.vue new file mode 100644 index 0000000..691f8e2 --- /dev/null +++ b/web/src/views/chat/ChatListView.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/web/src/views/constitution/ConstitutionResultView.vue b/web/src/views/constitution/ConstitutionResultView.vue new file mode 100644 index 0000000..a4bdb90 --- /dev/null +++ b/web/src/views/constitution/ConstitutionResultView.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/web/src/views/constitution/ConstitutionTestView.vue b/web/src/views/constitution/ConstitutionTestView.vue new file mode 100644 index 0000000..ac903da --- /dev/null +++ b/web/src/views/constitution/ConstitutionTestView.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/web/src/views/constitution/ConstitutionView.vue b/web/src/views/constitution/ConstitutionView.vue new file mode 100644 index 0000000..68f8b62 --- /dev/null +++ b/web/src/views/constitution/ConstitutionView.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/web/src/views/home/HomeView.vue b/web/src/views/home/HomeView.vue new file mode 100644 index 0000000..9b91e59 --- /dev/null +++ b/web/src/views/home/HomeView.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/web/src/views/layout/MainLayout.vue b/web/src/views/layout/MainLayout.vue new file mode 100644 index 0000000..6862618 --- /dev/null +++ b/web/src/views/layout/MainLayout.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/web/src/views/profile/HealthRecordView.vue b/web/src/views/profile/HealthRecordView.vue new file mode 100644 index 0000000..fd2f84a --- /dev/null +++ b/web/src/views/profile/HealthRecordView.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/web/src/views/profile/ProfileView.vue b/web/src/views/profile/ProfileView.vue new file mode 100644 index 0000000..b3090b1 --- /dev/null +++ b/web/src/views/profile/ProfileView.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..8d16e42 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..70b147f --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 5173, + host: true + } +})