Browse Source

fix: 修复 clone 后无法启动 — app 子模块转常规目录 & 补全环境模板

- app/ 从 gitlink(160000) 转为常规跟踪文件(47个),解决 clone 后 app 为空
- 新增 web/.env.example、mall/.env.example 环境变量模板
- .gitignore 放行 .env.example,新增 backend/healthapi/data/ 忽略
- README.md 完全重写:项目结构、技术栈、环境要求、从零搭建步骤、
  一键启动、环境变量配置、测试命令、常见问题排查、架构图

Made-with: Cursor
master
dark 2 weeks ago
parent
commit
ffa9b6f771
  1. 5
      .gitignore
  2. 398
      README.md
  3. 46
      agents.md
  4. 1
      app
  5. 41
      app/.gitignore
  6. 72
      app/App.tsx
  7. 30
      app/app.json
  8. BIN
      app/assets/adaptive-icon.png
  9. BIN
      app/assets/favicon.png
  10. BIN
      app/assets/icon.png
  11. BIN
      app/assets/splash-icon.png
  12. 8
      app/index.ts
  13. 14963
      app/package-lock.json
  14. 33
      app/package.json
  15. 71
      app/src/api/auth.ts
  16. 178
      app/src/api/config.ts
  17. 46
      app/src/api/constitution.ts
  18. 220
      app/src/api/conversation.ts
  19. 8
      app/src/api/index.ts
  20. 34
      app/src/api/product.ts
  21. 164
      app/src/api/request.ts
  22. 215
      app/src/api/types.ts
  23. 156
      app/src/api/user.ts
  24. 190
      app/src/components/AlertProvider.tsx
  25. 2
      app/src/components/index.ts
  26. 63
      app/src/mock/chat.ts
  27. 154
      app/src/mock/constitution.ts
  28. 4
      app/src/mock/index.ts
  29. 38
      app/src/mock/medication.ts
  30. 94
      app/src/mock/news.ts
  31. 32
      app/src/mock/products.ts
  32. 23
      app/src/mock/user.ts
  33. 183
      app/src/navigation/index.tsx
  34. 324
      app/src/screens/auth/LoginScreen.tsx
  35. 883
      app/src/screens/chat/ChatDetailScreen.tsx
  36. 456
      app/src/screens/chat/ChatListScreen.tsx
  37. 254
      app/src/screens/constitution/ConstitutionHomeScreen.tsx
  38. 519
      app/src/screens/constitution/ConstitutionResultScreen.tsx
  39. 314
      app/src/screens/constitution/ConstitutionTestScreen.tsx
  40. 434
      app/src/screens/home/HomeScreen.tsx
  41. 294
      app/src/screens/news/HealthNewsScreen.tsx
  42. 1627
      app/src/screens/profile/HealthProfileScreen.tsx
  43. 759
      app/src/screens/profile/ProfileScreen.tsx
  44. 201
      app/src/stores/authStore.ts
  45. 374
      app/src/stores/chatStore.ts
  46. 217
      app/src/stores/constitutionStore.ts
  47. 358
      app/src/stores/healthStore.ts
  48. 39
      app/src/stores/settingsStore.ts
  49. 44
      app/src/theme/index.ts
  50. 126
      app/src/types/index.ts
  51. 6
      app/tsconfig.json
  52. 5
      mall/.env.example
  53. 5
      web/.env.example

5
.gitignore

@ -34,13 +34,13 @@ ios/
bin/ bin/
vendor/ vendor/
# 环境变量 # 环境变量(保留 .env.example 模板)
.env .env
.env.local .env.local
.env.*.local .env.*.local
.env.development .env.development
.env.production .env.production
*.env !.env.example
# 日志文件 # 日志文件
logs/ logs/
@ -102,6 +102,7 @@ local.properties
# 数据库文件 # 数据库文件
*.db *.db
server/data/ server/data/
backend/healthapi/data/
# 本地/IDE 与测试产物 # 本地/IDE 与测试产物
.agents/ .agents/

398
README.md

@ -1,66 +1,376 @@
# 健康AI助手 - 原型项目 # 健康 AI 助手 — Health AI Assistant
基于中医体质辨识理论的智能健康咨询平台原型 基于中医体质辨识理论的智能健康管理平台,包含 Web 端、H5 商城、React Native APP 三个前端和统一 Go 后端
## 快速启动 ---
**Windows 用户:** ## 项目结构
双击项目根目录的 `start.bat` 文件,选择要启动的服务。
```
healthApps/
├── backend/ # 后端服务
│ ├── BACKEND.md # 后端开发文档
│ └── healthapi/ # Go-Zero API 服务
│ ├── etc/ # 配置文件
│ ├── internal/ # 业务代码 (handler / logic / model / svc / config)
│ ├── pkg/ # 公共包 (ai / jwt / errorx / response)
│ ├── tests/ # 后端集成测试
│ ├── healthapi.go # 入口文件
│ ├── healthapi.api # API 定义文件
│ ├── go.mod / go.sum # Go 依赖管理
│ └── data/ # SQLite 数据库文件(自动生成,已 gitignore)
├── web/ # 健康 AI 助手前端 (Vue 3)
├── mall/ # 健康商城前端 (Vue 3)
├── app/ # React Native APP (Expo)
├── scripts/ # 启动辅助脚本
│ ├── dev.js # 统一前端启动脚本
│ ├── start-web.bat # 单独启动 Web
│ ├── start-app.bat # 单独启动 APP
│ └── start-all.bat # 同时启动 Web + APP
├── tests/ # E2E 自动化测试 (Playwright)
├── TODOS/ # 开发任务文档
├── start.bat # Windows 启动入口
├── start.sh # Linux / macOS / Git Bash 启动入口
├── agents.md # Agents 开发规范与记录
├── design.md # 项目设计文档
├── mall-design.md # 商城设计文档
└── package.json # 根目录脚本 & 测试依赖
```
---
## 技术栈总览
| 模块 | 技术 | 端口 | 说明 |
|------|------|:----:|------|
| 后端 API | Go 1.22+ · Go-Zero · GORM · SQLite | 8080 | 统一后端,JWT 认证 |
| Web 前端 | Vue 3 · TypeScript · Vite · Element Plus · Pinia | 5173 | 健康 AI 助手 |
| 商城前端 | Vue 3 · TypeScript · Vite · Element Plus · Pinia | 5174 | 健康商城 H5 |
| APP 前端 | React Native · Expo 54 · React Navigation · Zustand | 8081 | 移动端 APP |
| E2E 测试 | Playwright · Chromium | - | 自动化功能测试 |
---
## 环境要求
| 工具 | 最低版本 | 用途 |
|------|---------|------|
| **Node.js** | v18+ (推荐 v22) | Web / Mall / APP 前端 & 测试 |
| **npm** | v9+ | 包管理 |
| **Go** | 1.22+ | 后端编译运行 |
| **Git** | 2.30+ | 版本管理 |
> APP 开发还需要 Expo CLI(随项目依赖安装),真机调试需 Expo Go 客户端。
---
## 快速上手(从零开始)
### 1. 克隆仓库
或者分别启动:
```bash ```bash
# 启动 Web 原型 git clone <仓库地址> healthApps
cd web && npm run dev cd healthApps
```
### 2. 后端启动
```bash
cd backend/healthapi
# 启动 APP 原型 # 安装 Go 依赖
cd app && npx expo start go mod download
# 首次需要创建 data 目录
mkdir -p data
# 直接运行(开发模式)
go run healthapi.go
# 或编译后运行
go build -o healthapi .
./healthapi # 默认读取 etc/healthapi-api.yaml
# ./healthapi -f etc/healthapi-api.yaml # 显式指定配置
``` ```
## 测试账号 后端启动后会自动完成:
- SQLite 数据库创建与表结构迁移(`data/health.db`)
- 体质测评问卷题库初始化(27 题)
- 测试用户创建
- **手机号**: `13800138000` 访问 `http://localhost:8080/api/health` 验证是否正常。
- **验证码**: `123456`
## 项目结构 ### 3. Web 前端启动
```bash
cd web
# 创建环境变量(从模板复制,通常不需要修改)
cp .env.example .env
# 安装依赖
npm install
# 启动开发服务
npm run dev
``` ```
healthApps/
├── web/ # Web 原型 (Vue 3 + Vite) 打开 http://localhost:5173
├── app/ # APP 原型 (React Native + Expo)
├── server/ # 后端服务 (Go + Gin) ### 4. 商城前端启动
├── scripts/ # 启动脚本
│ ├── start-web.bat ```bash
│ ├── start-app.bat cd mall
│ └── start-all.bat
├── start.bat # 主启动入口 # 创建环境变量
├── design.md # 项目设计文档 cp .env.example .env
└── TODOS/ # 开发任务文档
# 安装依赖
npm install
# 启动开发服务
npm run dev
```
打开 http://localhost:5174
### 5. APP 前端启动
```bash
cd app
# 安装依赖
npm install
# 启动 Expo 开发服务
npx expo start
# 或直接启动 Web 版
npx expo start --web
``` ```
## 技术栈 > APP 默认连接 `http://localhost:8080` 后端,配置在 `app/src/api/config.ts` 中。
---
## 一键启动(推荐)
| 项目 | 技术 | 端口 | 使用根目录提供的启动脚本,自动处理端口占用和依赖安装。
|-----|------|-----|
| Web | Vue 3 + TypeScript + Element Plus | 5173 | ### Windows
| APP | React Native + Expo + Paper | 8081 |
| 后端 | Go + Gin + SQLite | 8080 | ```cmd
start.bat
```
### Linux / macOS / Git Bash
```bash
chmod +x start.sh
./start.sh
```
启动菜单:
```
[1] Start Web (Vue 3 - 5173)
[2] Start Mall (Vue 3 - 5174)
[3] Start APP (React Native)
[4] Start Web + Mall
[5] Start Web + APP (仅 Windows)
[6] Exit
```
### npm 脚本
```bash
npm run dev # 同时启动 Web + Mall
npm run dev:web # 仅启动 Web
npm run dev:mall # 仅启动 Mall
npm run dev:kill # 关闭占用端口的进程
```
---
## 环境变量配置
### Web (`web/.env`)
```ini
VITE_API_BASE_URL=http://localhost:8080 # 后端 API
VITE_MALL_URL=http://localhost:5174 # 商城地址(跳转用)
```
### Mall (`mall/.env`)
```ini
VITE_API_BASE_URL=http://localhost:8080 # 后端 API
VITE_HEALTH_AI_URL=http://localhost:5173 # 健康助手地址(跳转用)
```
### 后端 (`backend/healthapi/etc/healthapi-api.yaml`)
```yaml
Port: 8080
Auth:
AccessSecret: health-ai-secret-key-change-in-production
Database:
Driver: sqlite
DataSource: ./data/health.db
AI:
Provider: aliyun
Aliyun:
ApiKey: <your-api-key> # 阿里云百炼 API Key
Model: qwen-plus
```
> 生产环境请务必修改 `AccessSecret``ApiKey`
---
## 测试账号
| 字段 | 值 |
|------|---|
| 手机号 | `13800138000` |
| 密码 / 验证码 | `123456` |
> 后端首次启动会自动创建此测试用户。
---
## 功能模块 ## 功能模块
- **用户登录** - 手机号验证码登录(模拟) ### 健康 AI 助手 (Web)
- **首页** - 体质概览、快捷入口、健康提示
- **体质测试** - 20道问卷,本地计算体质类型 - 手机号登录(验证码 / 密码)
- **AI问答** - 模拟AI健康咨询对话 - 体质辨识测评(9 种中医体质,27 道问卷)
- **个人中心** - 用户信息、健康档案 - AI 智能问答(流式输出,支持思考过程展示)
- 多轮对话管理(创建/删除/历史记录)
- 个人中心(健康档案、用药记录、体质报告)
### 健康商城 (Mall)
- 商品浏览(首页推荐、分类、搜索、详情)
- 按需登录(浏览免登录,下单/支付时登录)
- 购物车(增删改、批量操作、清空)
- 订单流程(预览 → 创建 → 支付 → 收货)
- 收货地址管理
- 会员中心(积分、订单状态、退出登录)
- 体质推荐商品(基于测评结果)
### APP (React Native)
- 与 Web 端相同的核心功能
- 原生移动端交互体验
- Expo Web 兼容(可在浏览器预览)
---
## E2E 自动化测试
```bash
# 安装测试依赖(仅首次)
npm install # 根目录 — 安装 Playwright
npx playwright install chromium
# 运行测试(需先启动对应前端)
node tests/mall.test.js # 商城前端 — Mock 数据(53 项)
node tests/mall-real.test.js # 商城前端 — 真实后端(52 项,需启动后端)
node tests/constitution.test.js # 体质测评
node tests/health-profile-complete.test.js # 健康档案
node tests/chat.test.js # AI 对话
node tests/profile.test.js # 个人中心
```
详见 [`tests/README.md`](tests/README.md)
---
## 原型说明 ## 常见问题
### Q: 克隆后前端启动报错 "Cannot find module"
确保在对应目录执行了 `npm install`
```bash
cd web && npm install
cd ../mall && npm install
cd ../app && npm install
```
### Q: 前端启动后页面空白或接口报错
检查 `.env` 文件是否存在。若不存在,从模板创建:
```bash
cp web/.env.example web/.env
cp mall/.env.example mall/.env
```
### Q: 后端启动报 "cannot open database"
手动创建 `data` 目录:
```bash
mkdir -p backend/healthapi/data
```
### Q: 端口被占用
```bash
npm run dev:kill # 自动清理 5173 / 5174 端口
```
或手动:
```bash
# Windows
netstat -ano | findstr :8080
taskkill /PID <pid> /F
# Linux / macOS
lsof -i :8080
kill -9 <pid>
```
### Q: APP 启动后无法连接后端
编辑 `app/src/api/config.ts`,确保 `API_BASE_URL` 指向后端地址。
真机调试时不能用 `localhost`,需改为电脑局域网 IP(如 `http://192.168.1.100:8080`)。
---
## 项目关系图
```
┌─────────────────┐
│ 后端 API :8080 │
│ Go-Zero + GORM │
│ SQLite / MySQL │
└────────┬────────┘
┌────────────────┼────────────────┐
│ │ │
┌────────▼───────┐ ┌─────▼──────┐ ┌───────▼──────┐
│ Web :5173 │ │ Mall :5174 │ │ APP :8081 │
│ Vue 3 + TS │ │ Vue 3 + TS │ │ RN + Expo │
│ 健康 AI 助手 │ │ 健康商城 │ │ 移动端 APP │
└────────┬───────┘ └─────┬──────┘ └──────────────┘
│ │
└──── 跨项目跳转 ┘
(共享 JWT Token via localStorage)
```
当前版本为原型演示版,使用本地模拟数据: ---
- 登录验证:模拟验证
- 体质测试:本地计算
- AI对话:关键词匹配模拟回复
- 数据存储:localStorage / AsyncStorage
## 后续开发 ## 开发文档
参考 `TODOS/` 目录下的开发文档进行后端对接。 | 文档 | 说明 |
|------|------|
| [`agents.md`](agents.md) | 开发规范、API 约定、测试规范、变更记录 |
| [`design.md`](design.md) | 项目总体设计文档 |
| [`mall-design.md`](mall-design.md) | 商城功能设计文档 |
| [`backend/BACKEND.md`](backend/BACKEND.md) | 后端开发文档 |
| [`tests/README.md`](tests/README.md) | 测试使用说明 |
| [`TODOS/`](TODOS/) | 开发任务与规划 |

46
agents.md

@ -693,3 +693,49 @@ cd backend && go run . # http://localhost:8080
- `mall` 执行 `npx vue-tsc --noEmit` 通过 - `mall` 执行 `npx vue-tsc --noEmit` 通过
- `tests/mall.test.js` 执行通过(62项,失败0) - `tests/mall.test.js` 执行通过(62项,失败0)
---
### 2026-02-03: 项目文档整理与子模块修复
**问题**: 其他机器 `git clone` 后无法启动项目。
**根因分析**:
1. `app/` 以 gitlink(mode 160000)记录在仓库中,但缺少 `.gitmodules` 文件,clone 后 `app/` 为空目录
2. `web/.env``mall/.env``.gitignore` 排除,clone 后前端无法得知后端地址
3. `README.md` 内容过时,缺少完整的环境搭建步骤
**修复内容**:
1. **app 子模块转为普通目录**:
- `git rm --cached app` 移除 gitlink 条目
- 删除 `app/.git`,消除嵌套仓库
- `git add app/` 将 47 个源文件纳入主仓库常规跟踪
- 解决了 clone 后 `app/` 为空的问题
2. **环境变量模板**:
- 新增 `web/.env.example` — 包含 `VITE_API_BASE_URL``VITE_MALL_URL`
- 新增 `mall/.env.example` — 包含 `VITE_API_BASE_URL``VITE_HEALTH_AI_URL`
- `.gitignore` 添加 `!.env.example` 放行规则
3. **README.md 重写**:
- 完整项目结构树
- 技术栈总览表(后端/Web/Mall/APP/测试)
- 环境要求(Node、Go、Git 版本)
- 从零开始的详细搭建步骤(后端 → Web → Mall → APP)
- 一键启动说明(start.bat / start.sh / npm scripts)
- 环境变量配置参考
- 常见问题排查(依赖缺失、.env 缺失、端口占用、数据库目录、APP 连接)
- 项目关系架构图
**新增文件**:
- `web/.env.example`
- `mall/.env.example`
**修改文件**:
- `README.md` — 完全重写
- `.gitignore` — 添加 `!.env.example``backend/healthapi/data/`
- `app/` — 从 gitlink 转为 47 个常规跟踪文件

1
app

@ -1 +0,0 @@
Subproject commit 50050b66cc4db674e781f1b70fd4e5d322f47bb5

41
app/.gitignore

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

72
app/App.tsx

@ -0,0 +1,72 @@
import React, { useEffect, useState } from "react";
import { StatusBar } from "expo-status-bar";
import { PaperProvider } from "react-native-paper";
import { View, ActivityIndicator, StyleSheet } from "react-native";
import Navigation from "./src/navigation";
import { theme, colors } from "./src/theme";
import { useAuthStore } from "./src/stores/authStore";
import { useConstitutionStore } from "./src/stores/constitutionStore";
import { useChatStore } from "./src/stores/chatStore";
import { useSettingsStore } from "./src/stores/settingsStore";
import { AlertProvider } from "./src/components";
export default function App() {
const [isReady, setIsReady] = useState(false);
const initAuth = useAuthStore((state) => state.init);
const user = useAuthStore((state) => state.user);
const initConstitution = useConstitutionStore((state) => state.init);
const fetchConstitutionResult = useConstitutionStore(
(state) => state.fetchResult
);
const initChat = useChatStore((state) => state.init);
const initSettings = useSettingsStore((state) => state.init);
useEffect(() => {
async function init() {
try {
await Promise.all([
initAuth(),
initConstitution(),
initChat(),
initSettings(),
]);
} finally {
setIsReady(true);
}
}
init();
}, []);
// 登录状态变化时重新获取体质结果
useEffect(() => {
if (user) {
fetchConstitutionResult();
}
}, [user]);
if (!isReady) {
return (
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<PaperProvider theme={theme}>
<AlertProvider>
<StatusBar style="auto" />
<Navigation />
</AlertProvider>
</PaperProvider>
);
}
const styles = StyleSheet.create({
loading: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.background,
},
});

30
app/app.json

@ -0,0 +1,30 @@
{
"expo": {
"name": "app",
"slug": "app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

BIN
app/assets/adaptive-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
app/assets/favicon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
app/assets/icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
app/assets/splash-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
app/index.ts

@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

14963
app/package-lock.json

File diff suppressed because it is too large

33
app/package.json

@ -0,0 +1,33 @@
{
"name": "app",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.10.1",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.11.0",
"expo": "~54.0.33",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-paper": "^5.14.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-vector-icons": "^10.3.0",
"react-native-web": "^0.21.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

71
app/src/api/auth.ts

@ -0,0 +1,71 @@
// 认证模块 API
import { post } from './request'
import { setToken, setRefreshToken, clearTokens, ApiResponse } from './config'
import type { User } from '../types'
export interface LoginRequest {
phone: string
password?: string // 密码登录
code?: string // 验证码登录
}
export interface RegisterRequest {
phone: string
password: string
code: string
}
// 后端实际返回的响应结构
export interface AuthResponse {
token: string // JWT Token 直接字符串
user_id: number
nickname: string
avatar: string
survey_completed: boolean
}
// 发送验证码
export async function sendCode(phone: string, type: 'login' | 'register' = 'login') {
return post<null>('/auth/send-code', { phone, type })
}
// 用户登录(支持密码或验证码)
export async function login(data: LoginRequest): Promise<ApiResponse<AuthResponse>> {
// 如果只传了code,将code作为password发送(验证码登录模式)
const loginData = {
phone: data.phone,
password: data.password || data.code, // 验证码登录时,code作为password
}
console.log('[Login] 请求数据:', JSON.stringify(loginData))
const result = await post<AuthResponse>('/auth/login', loginData)
console.log('[Login] 响应结果:', JSON.stringify(result))
// 后端直接返回 token 字符串,不是对象
if (result.code === 0 && result.data?.token) {
console.log('[Login] 准备保存Token:', result.data.token.substring(0, 30) + '...')
await setToken(result.data.token)
console.log('[Login] Token已保存')
}
return result
}
// 用户注册
export async function register(data: RegisterRequest): Promise<ApiResponse<AuthResponse>> {
const result = await post<AuthResponse>('/auth/register', data)
// 后端直接返回 token 字符串
if (result.code === 0 && result.data?.token) {
await setToken(result.data.token)
}
return result
}
// 退出登录
export async function logout() {
await clearTokens()
}

178
app/src/api/config.ts

@ -0,0 +1,178 @@
// API 基础配置
import AsyncStorage from '@react-native-async-storage/async-storage'
// API 基础地址 - 根据环境配置
export const API_BASE_URL = __DEV__
? 'http://localhost:8080/api' // 开发环境
: 'https://api.health-ai.com/api' // 生产环境
// 请求超时时间
export const REQUEST_TIMEOUT = 30000
// Token 存储键
export const TOKEN_KEY = 'health_ai_token'
export const REFRESH_TOKEN_KEY = 'health_ai_refresh_token'
// 判断是否为 Web 环境 - 检测 window 和 localStorage
const isWeb = typeof window !== 'undefined' && typeof localStorage !== 'undefined'
console.log('[Config] 运行环境:', isWeb ? 'Web' : 'Native')
// 存储封装 - Web 使用 localStorage,原生使用 AsyncStorage
const storage = {
getItem: (key: string): string | null => {
if (isWeb) {
const value = localStorage.getItem(key)
return value
}
// 原生环境返回 null,需要异步获取
return null
},
getItemAsync: async (key: string): Promise<string | null> => {
if (isWeb) {
return localStorage.getItem(key)
}
return AsyncStorage.getItem(key)
},
setItem: (key: string, value: string): void => {
if (isWeb) {
localStorage.setItem(key, value)
return
}
},
setItemAsync: async (key: string, value: string): Promise<void> => {
if (isWeb) {
localStorage.setItem(key, value)
return
}
await AsyncStorage.setItem(key, value)
},
removeItem: (key: string): void => {
if (isWeb) {
localStorage.removeItem(key)
return
}
},
removeItemAsync: async (key: string): Promise<void> => {
if (isWeb) {
localStorage.removeItem(key)
return
}
await AsyncStorage.removeItem(key)
},
multiRemove: async (keys: string[]): Promise<void> => {
if (isWeb) {
keys.forEach(key => localStorage.removeItem(key))
return
}
await AsyncStorage.multiRemove(keys)
}
}
// 获取存储的 Token(同步版本,用于Web)
export const getTokenSync = (): string | null => {
if (isWeb) {
const token = localStorage.getItem(TOKEN_KEY)
return token
}
return null
}
// 获取存储的 Token
export const getToken = async (): Promise<string | null> => {
try {
let token: string | null = null
// Web 环境直接同步获取
if (isWeb) {
token = localStorage.getItem(TOKEN_KEY)
} else {
// 原生环境异步获取
token = await AsyncStorage.getItem(TOKEN_KEY)
}
// 验证 token 是否有效(排除 "undefined", "null", 空字符串等无效值)
if (!token || token === 'undefined' || token === 'null' || token.trim() === '') {
console.log('[Token] 获取Token: 无效或不存在')
return null
}
console.log('[Token] 获取Token:', `${token.substring(0, 20)}...`)
return token
} catch (e) {
console.log('[Token] 获取Token错误:', e)
return null
}
}
// 保存 Token
export const setToken = async (token: string): Promise<void> => {
// 验证 token 有效性
if (!token || token === 'undefined' || token === 'null') {
console.log('[Token] 保存Token: 无效值,跳过保存')
return
}
console.log('[Token] 保存Token:', `${token.substring(0, 20)}...`)
if (isWeb) {
localStorage.setItem(TOKEN_KEY, token)
// 验证保存成功
const saved = localStorage.getItem(TOKEN_KEY)
console.log('[Token] 验证保存:', saved === token ? '成功' : '失败')
return
}
await AsyncStorage.setItem(TOKEN_KEY, token)
}
// 保存 Refresh Token
export const setRefreshToken = async (token: string): Promise<void> => {
if (isWeb) {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
return
}
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, token)
}
// 获取 Refresh Token
export const getRefreshToken = async (): Promise<string | null> => {
try {
if (isWeb) {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
return await AsyncStorage.getItem(REFRESH_TOKEN_KEY)
} catch {
return null
}
}
// 清除 Token
export const clearTokens = async (): Promise<void> => {
if (isWeb) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
return
}
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY])
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 错误码
export const ErrorCode = {
SUCCESS: 0,
PARAM_ERROR: 40001,
CODE_ERROR: 40002,
USER_EXISTS: 40003,
UNAUTHORIZED: 40101,
TOKEN_EXPIRED: 40102,
TOKEN_INVALID: 40103,
FORBIDDEN: 40301,
NOT_FOUND: 40401,
SERVER_ERROR: 50001,
AI_ERROR: 50002,
}

46
app/src/api/constitution.ts

@ -0,0 +1,46 @@
// 体质辨识模块 API
import { get, post } from "./request";
import type { ConstitutionQuestion, ConstitutionResult } from "../types";
// 后端实际返回格式是 data: ConstitutionQuestion[]
// 保留此类型以兼容可能的未来格式变化
export interface QuestionsResponse {
total?: number;
questions?: ConstitutionQuestion[];
}
export interface SubmitAnswerRequest {
answers: { question_id: number; score: number }[];
}
export interface Recommendations {
diet: string[];
lifestyle: string[];
exercise: string[];
emotion: string[];
}
// 获取问卷题目
export function getQuestions() {
return get<QuestionsResponse>("/constitution/questions");
}
// 提交问卷答案
export function submitAnswers(data: SubmitAnswerRequest) {
return post<ConstitutionResult>("/constitution/submit", data);
}
// 获取最新体质结果
export function getResult() {
return get<ConstitutionResult>("/constitution/result");
}
// 获取体质测评历史
export function getHistory() {
return get<ConstitutionResult[]>("/constitution/history");
}
// 获取调养建议
export function getRecommendations() {
return get<Recommendations>("/constitution/recommendations");
}

220
app/src/api/conversation.ts

@ -0,0 +1,220 @@
// AI对话模块 API
import { get, post, del } from "./request";
import { API_BASE_URL, getToken } from "./config";
import type {
ConversationItem,
ConversationDetail,
MessageItem,
} from "./types";
/**
* API API.md :
* - 列表: data: ConversationItem[] ()
* - 详情: data: ConversationDetail ( id, title, messages, created_at, updated_at)
* - 消息: data: MessageItem
*/
// 获取对话列表 - 后端返回: data: ConversationItem[]
export function getConversations(page = 1, limit = 20) {
return get<ConversationItem[]>("/conversations", { page, limit });
}
// 创建新对话 - 后端返回: data: ConversationItem
export function createConversation(title?: string) {
return post<ConversationItem>("/conversations", { title });
}
// 获取对话详情 - 后端返回: data: ConversationDetail
export function getConversationDetail(id: string) {
return get<ConversationDetail>(`/conversations/${id}`);
}
// 删除对话
export function deleteConversation(id: string) {
return del<null>(`/conversations/${id}`);
}
// 思考状态类型
export type ThinkingState = "start" | "thinking" | "end";
// 发送消息(支持流式和非流式响应,包含思考过程)
export async function sendMessage(
conversationId: string,
content: string,
onChunk: (chunk: string) => void,
onDone: (messageId: string) => void,
onError: (error: string) => void,
onThinking?: (state: ThinkingState, content?: string) => void // 可选的思考过程回调
) {
const token = await getToken();
console.log(
"[SendMessage] Token:",
token ? `${token.substring(0, 20)}...` : "null"
);
console.log(
"[SendMessage] URL:",
`${API_BASE_URL}/conversations/${conversationId}/messages/stream`
);
if (!token) {
onError("未登录,请重新登录");
return;
}
try {
// 使用流式端点
const response = await fetch(
`${API_BASE_URL}/conversations/${conversationId}/messages/stream`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
Accept: "text/event-stream",
},
body: JSON.stringify({ content }),
}
);
console.log("[SendMessage] 响应状态:", response.status);
console.log(
"[SendMessage] Content-Type:",
response.headers.get("content-type")
);
if (!response.ok) {
const errorData = await response.json();
onError(errorData.message || "发送失败");
return;
}
const contentType = response.headers.get("content-type") || "";
// 如果是普通 JSON 响应(非流式)
if (contentType.includes("application/json")) {
console.log("[SendMessage] 检测到 JSON 响应,使用非流式处理");
const jsonData = await response.json();
console.log(
"[SendMessage] JSON 响应数据:",
JSON.stringify(jsonData).substring(0, 200)
);
// 处理后端返回的消息
if (jsonData.code === 0 && jsonData.data) {
const aiMessage =
jsonData.data.ai_message ||
jsonData.data.assistant_message ||
jsonData.data;
if (aiMessage && aiMessage.content) {
onChunk(aiMessage.content);
onDone(aiMessage.id || `msg_${Date.now()}`);
} else if (jsonData.data.content) {
onChunk(jsonData.data.content);
onDone(jsonData.data.id || `msg_${Date.now()}`);
} else {
onError("响应格式错误");
}
} else {
onError(jsonData.message || "发送失败");
}
return;
}
// SSE 流式响应处理
const reader = response.body?.getReader();
if (!reader) {
onError("无法读取响应");
return;
}
const decoder = new TextDecoder();
let buffer = "";
let hasReceivedData = false;
let fullContent = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("[SendMessage] 流读取完成");
break;
}
const chunk = decoder.decode(value, { stream: true });
console.log("[SendMessage] 收到数据块:", chunk.substring(0, 100));
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
hasReceivedData = true;
if (data.type === "thinking_start") {
// 开始思考
onThinking?.("start");
} else if (data.type === "thinking") {
// 思考过程内容
onThinking?.("thinking", data.content);
} else if (data.type === "thinking_end") {
// 思考结束
onThinking?.("end");
} else if (data.type === "content") {
fullContent += data.content;
onChunk(data.content);
} else if (data.type === "end") {
onDone(data.message_id || `msg_${Date.now()}`);
return;
} else if (data.type === "error") {
onError(data.message);
return;
}
} catch (e) {
console.log("[SendMessage] JSON解析错误:", line);
}
}
}
}
// 流结束但没有收到 end 事件,尝试处理最后的 buffer
if (buffer.trim()) {
console.log("[SendMessage] 处理剩余buffer:", buffer);
if (buffer.startsWith("data: ")) {
try {
const data = JSON.parse(buffer.slice(6));
if (data.type === "content") {
fullContent += data.content;
onChunk(data.content);
}
if (data.type === "end" || data.message_id) {
onDone(data.message_id || `msg_${Date.now()}`);
return;
}
} catch (e) {
console.log("[SendMessage] 最终buffer解析失败");
}
}
}
// 如果收到了数据但没有明确的 end 事件,也标记完成
if (hasReceivedData || fullContent) {
console.log("[SendMessage] 流结束,手动触发onDone");
onDone(`msg_${Date.now()}`);
} else {
onError("未收到有效响应");
}
} catch (error: any) {
console.log("[SendMessage] 错误:", error);
onError(error.message || "网络错误");
}
}
// 发送消息(非流式,备用)
export function sendMessageSync(conversationId: string, content: string) {
return post<{ user_message: Message; ai_message: Message }>(
`/conversations/${conversationId}/messages`,
{ content, stream: false }
);
}

8
app/src/api/index.ts

@ -0,0 +1,8 @@
// API 模块统一导出
export * from './config'
export * from './request'
export * as authApi from './auth'
export * as userApi from './user'
export * as constitutionApi from './constitution'
export * as conversationApi from './conversation'
export * as productApi from './product'

34
app/src/api/product.ts

@ -0,0 +1,34 @@
// 产品模块 API
import { get } from './request'
import type { Product } from '../types'
export interface ProductListResponse {
products: Product[]
total: number
}
export interface RecommendResponse {
constitution_type: string
constitution_name: string
products: Product[]
}
// 获取产品列表
export function getProducts(params?: { category?: string; page?: number; limit?: number }) {
return get<ProductListResponse>('/products', params)
}
// 获取产品详情
export function getProductDetail(id: number) {
return get<Product>(`/products/${id}`)
}
// 获取体质推荐产品
export function getRecommendProducts(constitutionType?: string) {
return get<RecommendResponse>('/products/recommend', { constitution_type: constitutionType })
}
// 搜索产品
export function searchProducts(keyword: string) {
return get<ProductListResponse>('/products/search', { keyword })
}

164
app/src/api/request.ts

@ -0,0 +1,164 @@
// HTTP 请求封装
import {
API_BASE_URL,
REQUEST_TIMEOUT,
getToken,
getRefreshToken,
setToken,
setRefreshToken,
clearTokens,
ApiResponse,
ErrorCode
} from './config'
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
headers?: Record<string, string>
body?: any
timeout?: number
}
// Token 刷新锁
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
const subscribeTokenRefresh = (cb: (token: string) => void) => {
refreshSubscribers.push(cb)
}
const onTokenRefreshed = (token: string) => {
refreshSubscribers.forEach(cb => cb(token))
refreshSubscribers = []
}
// 刷新 Token
const refreshToken = async (): Promise<string | null> => {
try {
const refresh = await getRefreshToken()
if (!refresh) return null
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh })
})
const result: ApiResponse = await response.json()
if (result.code === ErrorCode.SUCCESS) {
await setToken(result.data.access_token)
await setRefreshToken(result.data.refresh_token)
return result.data.access_token
}
return null
} catch {
return null
}
}
// 基础请求函数
export async function request<T = any>(
url: string,
options: RequestOptions = {}
): Promise<ApiResponse<T>> {
const { method = 'GET', headers = {}, body, timeout = REQUEST_TIMEOUT } = options
// 获取 Token
const token = await getToken()
// 构建请求头
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
}
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`
}
// 构建完整 URL
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`
console.log(`[API] ${method} ${fullUrl}`, token ? '(有Token)' : '(无Token)')
// 创建超时控制器
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(fullUrl, {
method,
headers: requestHeaders,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
clearTimeout(timeoutId)
const result: ApiResponse<T> = await response.json()
// Token 过期,尝试刷新
if (result.code === ErrorCode.TOKEN_EXPIRED) {
if (!isRefreshing) {
isRefreshing = true
const newToken = await refreshToken()
isRefreshing = false
if (newToken) {
onTokenRefreshed(newToken)
// 重试请求
return request<T>(url, options)
} else {
// 刷新失败,清除 Token
await clearTokens()
throw new Error('登录已过期,请重新登录')
}
} else {
// 等待 Token 刷新
return new Promise((resolve) => {
subscribeTokenRefresh(() => {
resolve(request<T>(url, options))
})
})
}
}
return result
} catch (error: any) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
return { code: -1, message: '请求超时', data: null as any }
}
return { code: -1, message: error.message || '网络错误', data: null as any }
}
}
// GET 请求
export function get<T = any>(url: string, params?: Record<string, any>) {
let fullUrl = url
if (params) {
const queryString = Object.entries(params)
.filter(([_, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
if (queryString) {
fullUrl += (url.includes('?') ? '&' : '?') + queryString
}
}
return request<T>(fullUrl, { method: 'GET' })
}
// POST 请求
export function post<T = any>(url: string, data?: any) {
return request<T>(url, { method: 'POST', body: data })
}
// PUT 请求
export function put<T = any>(url: string, data?: any) {
return request<T>(url, { method: 'PUT', body: data })
}
// DELETE 请求
export function del<T = any>(url: string) {
return request<T>(url, { method: 'DELETE' })
}

215
app/src/api/types.ts

@ -0,0 +1,215 @@
/**
* API
*
*
* 1. : { code: number, message: string, data: T }
* 2. 列表接口直接返回数组: data: T[]
* 3. 详情接口返回对象: data: { id, ... }
* 4. code=0, code>0
*/
// ========================== 基础响应 ==========================
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
// ========================== 认证模块 ==========================
// 登录/注册响应
export interface AuthResponse {
token: string; // JWT Token
user_id: number;
nickname: string;
avatar: string;
survey_completed: boolean;
}
// 刷新Token响应
export interface RefreshTokenResponse {
token: string;
}
// 发送验证码响应
export interface SendCodeResponse {
phone: string; // 脱敏手机号
expires_in: number; // 过期时间(秒)
demo_code?: string; // 演示环境验证码
}
// ========================== 用户模块 ==========================
// 用户信息
export interface UserProfile {
user_id: number;
phone: string;
email: string;
nickname: string;
avatar: string;
survey_completed: boolean;
}
// 健康档案
export interface HealthProfile {
id: number;
user_id: number;
name: string;
birth_date: string;
gender: "male" | "female";
height: number;
weight: number;
bmi: number;
blood_type: string;
occupation: string;
marital_status: string;
region: string;
}
// 生活习惯
export interface Lifestyle {
id: number;
user_id: number;
sleep_time: string;
wake_time: string;
sleep_quality: "good" | "normal" | "poor";
meal_regularity: "regular" | "irregular";
diet_preference: string;
daily_water_ml: number;
exercise_frequency: "never" | "sometimes" | "often" | "daily";
exercise_type: string;
exercise_duration_min: number;
is_smoker: boolean;
alcohol_frequency: "never" | "sometimes" | "often";
}
// 病史记录
export interface MedicalHistory {
id: number;
user_id: number;
disease_name: string;
disease_type: "chronic" | "surgery" | "other";
diagnosed_date: string;
status: "cured" | "treating" | "controlled";
notes: string;
created_at: string;
updated_at: string;
}
// 家族病史
export interface FamilyHistory {
id: number;
user_id: number;
relation: "father" | "mother" | "grandparent";
disease_name: string;
notes: string;
created_at: string;
updated_at: string;
}
// 过敏记录
export interface AllergyRecord {
id: number;
user_id: number;
allergy_type: "drug" | "food" | "other";
allergen: string;
severity: "mild" | "moderate" | "severe";
reaction_desc: string;
created_at: string;
updated_at: string;
}
// ========================== 对话模块 ==========================
// 对话列表项(不含消息)
export interface ConversationItem {
id: number;
title: string;
created_at: string;
updated_at: string;
}
// 对话详情(含消息)
export interface ConversationDetail {
id: number;
title: string;
messages: MessageItem[];
created_at: string;
updated_at: string;
}
// 消息项
export interface MessageItem {
id: number;
role: "user" | "assistant";
content: string;
created_at: string;
}
// ========================== 体质模块 ==========================
// 体质问题
export interface ConstitutionQuestion {
id: number;
constitution_type: string;
question_text: string;
options: string; // JSON string
order_num: number;
}
// 体质分数
export interface ConstitutionScore {
type: string;
name: string;
score: number;
description: string;
}
// 体质测评结果
export interface ConstitutionResult {
id: number;
primary_constitution: ConstitutionScore;
secondary_constitutions: ConstitutionScore[];
all_scores: ConstitutionScore[];
recommendations: Record<
string,
{
diet: string;
lifestyle: string;
exercise: string;
emotion: string;
}
>;
assessed_at: string;
}
// ========================== 产品模块 ==========================
// 产品信息
export interface Product {
id: number;
name: string;
category: string;
description: string;
price: number;
image: string;
suitable_constitutions: string[];
}
// ========================== 错误码 ==========================
export const ErrorCode = {
SUCCESS: 0,
PARAM_ERROR: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
SERVER_ERROR: 500,
// 自定义错误码
CODE_ERROR: 40002,
USER_EXISTS: 40003,
TOKEN_EXPIRED: 40102,
TOKEN_INVALID: 40103,
AI_ERROR: 50002,
} as const;

156
app/src/api/user.ts

@ -0,0 +1,156 @@
// 用户模块 API
import { get, post, put, del } from "./request";
import type { User } from "../types";
export interface HealthProfile {
id: number;
user_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;
medical_histories?: MedicalHistory[];
allergy_records?: AllergyRecord[];
family_histories?: FamilyHistory[];
}
export interface MedicalHistory {
id: number;
disease_name: string;
disease_type: string; // chronic/surgery/other
diagnosed_date: string;
status: string; // cured/treating/controlled
notes: string;
}
export interface AllergyRecord {
id: number;
allergy_type: string; // drug/food/other
allergen: string;
severity: string; // mild/moderate/severe
reaction_desc: string;
}
export interface FamilyHistory {
id: number;
relation: string; // father/mother/grandparent
disease_name: string;
notes: string;
}
export interface Lifestyle {
id: number;
user_id: number;
sleep_time: string;
wake_time: string;
sleep_quality: string; // good/normal/poor
meal_regularity: string; // regular/irregular
diet_preference: string;
daily_water_ml: number;
exercise_frequency: string; // never/sometimes/often/daily
exercise_type: string;
exercise_duration_min: number;
is_smoker: boolean;
alcohol_frequency: string; // never/sometimes/often
}
// 获取用户信息
export function getUserProfile() {
return get<User>("/user/profile");
}
// 更新用户信息
export function updateUserProfile(data: Partial<User>) {
return put<User>("/user/profile", data);
}
// 获取健康档案(完整)
export function getHealthProfile() {
return get<HealthProfile>("/user/health-profile");
}
// 更新健康档案
export function updateHealthProfile(data: Partial<HealthProfile>) {
return put<HealthProfile>("/user/health-profile", data);
}
// 获取基础档案
export function getBasicProfile() {
return get<HealthProfile>("/user/basic-profile");
}
// 获取生活习惯
export function getLifestyle() {
return get<Lifestyle>("/user/lifestyle");
}
// 更新生活习惯
export function updateLifestyle(data: Partial<Lifestyle>) {
return put<Lifestyle>("/user/lifestyle", data);
}
// 获取病史列表
export function getMedicalHistory() {
return get<MedicalHistory[]>("/user/medical-history");
}
// 删除病史记录
export function deleteMedicalHistory(id: number) {
return del<null>(`/user/medical-history/${id}`);
}
// 获取家族病史
export function getFamilyHistory() {
return get<FamilyHistory[]>("/user/family-history");
}
// 删除家族病史
export function deleteFamilyHistory(id: number) {
return del<null>(`/user/family-history/${id}`);
}
// 获取过敏记录
export function getAllergyRecords() {
return get<AllergyRecord[]>("/user/allergy-records");
}
// 删除过敏记录
export function deleteAllergyRecord(id: number) {
return del<null>(`/user/allergy-records/${id}`);
}
// 添加病史记录
export function addMedicalHistory(data: {
disease_name: string;
disease_type: string;
diagnosed_date: string;
status: string;
notes?: string;
}) {
return post<MedicalHistory>("/survey/medical-history", data);
}
// 添加家族病史
export function addFamilyHistory(data: {
relation: string;
disease_name: string;
notes?: string;
}) {
return post<FamilyHistory>("/survey/family-history", data);
}
// 添加过敏记录
export function addAllergyRecord(data: {
allergy_type: string;
allergen: string;
severity: string;
reaction_desc?: string;
}) {
return post<AllergyRecord>("/survey/allergy", data);
}

190
app/src/components/AlertProvider.tsx

@ -0,0 +1,190 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { StyleSheet } from 'react-native'
import { Snackbar, Dialog, Portal, Button, Text } from 'react-native-paper'
import { colors } from '../theme'
// Alert 按钮类型
export interface AlertButton {
text: string
style?: 'default' | 'cancel' | 'destructive'
onPress?: () => void
}
// Alert 配置
interface AlertConfig {
title: string
message?: string
buttons?: AlertButton[]
}
// Toast 配置
interface ToastConfig {
message: string
duration?: number
}
// Context 类型
interface AlertContextType {
showAlert: (title: string, message?: string, buttons?: AlertButton[]) => void
showToast: (message: string, duration?: number) => void
hideAlert: () => void
hideToast: () => void
}
const AlertContext = createContext<AlertContextType | null>(null)
// 导出 hook
export function useAlert() {
const context = useContext(AlertContext)
if (!context) {
throw new Error('useAlert must be used within AlertProvider')
}
return context
}
interface AlertProviderProps {
children: ReactNode
}
export function AlertProvider({ children }: AlertProviderProps) {
// Dialog 状态
const [alertVisible, setAlertVisible] = useState(false)
const [alertConfig, setAlertConfig] = useState<AlertConfig | null>(null)
// Snackbar 状态
const [toastVisible, setToastVisible] = useState(false)
const [toastConfig, setToastConfig] = useState<ToastConfig | null>(null)
// 显示 Alert Dialog
const showAlert = useCallback((title: string, message?: string, buttons?: AlertButton[]) => {
setAlertConfig({
title,
message,
buttons: buttons || [{ text: '确定', style: 'default' }]
})
setAlertVisible(true)
}, [])
// 隐藏 Alert Dialog
const hideAlert = useCallback(() => {
setAlertVisible(false)
// 延迟清除配置,避免动画中内容消失
setTimeout(() => setAlertConfig(null), 200)
}, [])
// 显示 Toast
const showToast = useCallback((message: string, duration = 3000) => {
setToastConfig({ message, duration })
setToastVisible(true)
}, [])
// 隐藏 Toast
const hideToast = useCallback(() => {
setToastVisible(false)
}, [])
// 处理按钮点击
const handleButtonPress = useCallback((button: AlertButton) => {
hideAlert()
// 延迟执行回调,确保 Dialog 关闭后再执行
setTimeout(() => {
button.onPress?.()
}, 100)
}, [hideAlert])
// 获取按钮样式
const getButtonMode = (style?: string): 'text' | 'outlined' | 'contained' => {
if (style === 'destructive') return 'contained'
if (style === 'cancel') return 'outlined'
return 'text'
}
const getButtonColor = (style?: string): string => {
if (style === 'destructive') return colors.danger
return colors.primary
}
return (
<AlertContext.Provider value={{ showAlert, showToast, hideAlert, hideToast }}>
{children}
{/* Alert Dialog */}
<Portal>
<Dialog
visible={alertVisible}
onDismiss={hideAlert}
style={styles.dialog}
>
{alertConfig?.title && (
<Dialog.Title style={styles.title}>{alertConfig.title}</Dialog.Title>
)}
{alertConfig?.message && (
<Dialog.Content>
<Text style={styles.message}>{alertConfig.message}</Text>
</Dialog.Content>
)}
<Dialog.Actions style={styles.actions}>
{alertConfig?.buttons?.map((button, index) => (
<Button
key={index}
mode={getButtonMode(button.style)}
onPress={() => handleButtonPress(button)}
textColor={button.style === 'destructive' ? '#fff' : getButtonColor(button.style)}
buttonColor={button.style === 'destructive' ? colors.danger : undefined}
style={styles.button}
>
{button.text}
</Button>
))}
</Dialog.Actions>
</Dialog>
</Portal>
{/* Toast Snackbar */}
<Snackbar
visible={toastVisible}
onDismiss={hideToast}
duration={toastConfig?.duration || 3000}
style={styles.snackbar}
action={{
label: '关闭',
onPress: hideToast
}}
>
{toastConfig?.message || ''}
</Snackbar>
</AlertContext.Provider>
)
}
const styles = StyleSheet.create({
dialog: {
borderRadius: 16,
maxWidth: 400,
alignSelf: 'center',
width: '90%'
},
title: {
fontSize: 18,
fontWeight: '600',
color: colors.textPrimary
},
message: {
fontSize: 15,
lineHeight: 22,
color: colors.textSecondary
},
actions: {
paddingHorizontal: 16,
paddingBottom: 16,
gap: 8
},
button: {
minWidth: 64
},
snackbar: {
marginBottom: 80
}
})
export default AlertProvider

2
app/src/components/index.ts

@ -0,0 +1,2 @@
export { AlertProvider, useAlert } from './AlertProvider'
export type { AlertButton } from './AlertProvider'

63
app/src/mock/chat.ts

@ -0,0 +1,63 @@
// AI 模拟回复
const aiReplies: Record<string, string> = {
'疲劳': `【情况分析】您可能存在气虚的情况,容易感到疲劳乏力。
1. 7-8
2.
3.
-
`,
'失眠': `【情况分析】失眠可能与心神不宁有关。
1. 1使
2. 15-20
3.
-
2`,
'感冒': `【情况分析】普通感冒多为病毒感染引起。
1.
2.
3.
-
38.5°C持续3天`
}
// 默认回复
const defaultReply = `【情况分析】感谢您的咨询,我会尽力为您提供健康建议。
1.
2.
3.
`
// 模拟 AI 回复
export function mockAIReply(message: string): Promise<string> {
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)
})
}

154
app/src/mock/constitution.ts

@ -0,0 +1,154 @@
import type { ConstitutionQuestion, ConstitutionResult, ConstitutionType } from '../types'
// 体质名称映射
export const constitutionNames: Record<ConstitutionType, string> = {
pinghe: '平和质',
qixu: '气虚质',
yangxu: '阳虚质',
yinxu: '阴虚质',
tanshi: '痰湿质',
shire: '湿热质',
xueyu: '血瘀质',
qiyu: '气郁质',
tebing: '特禀质'
}
// 体质详细描述
export const constitutionDescriptions: Record<ConstitutionType, {
description: string
features: string[]
suggestions: string[]
}> = {
pinghe: {
description: '阴阳气血调和,体态适中,面色红润,精力充沛',
features: ['体态适中', '面色红润', '精力充沛', '睡眠良好'],
suggestions: ['饮食均衡,不偏食', '起居有常,劳逸结合', '适量运动,量力而行', '保持乐观心态']
},
qixu: {
description: '元气不足,容易疲劳,气短懒言,易出汗',
features: ['容易疲劳', '气短懒言', '易出汗', '抵抗力差'],
suggestions: ['宜食益气健脾食物', '避免劳累,保证睡眠', '宜柔和运动如太极', '避免过度思虑']
},
yangxu: {
description: '阳气不足,畏寒怕冷,手脚冰凉,喜热饮',
features: ['畏寒怕冷', '手脚冰凉', '喜热饮食', '精神不振'],
suggestions: ['宜食温阳食物如羊肉', '注意保暖,避免受寒', '宜温和运动,避免大汗', '保持积极乐观']
},
yinxu: {
description: '阴液亏少,口燥咽干,手足心热,盗汗',
features: ['口干咽燥', '手足心热', '盗汗', '皮肤干燥'],
suggestions: ['宜食滋阴食物如百合', '避免熬夜,环境保湿', '宜静养,避免剧烈运动', '避免急躁易怒']
},
tanshi: {
description: '痰湿凝聚,形体肥胖,腹部肥满,痰多',
features: ['形体肥胖', '腹部肥满', '痰多', '身体沉重'],
suggestions: ['饮食清淡,少食肥甘', '居住环境宜干燥通风', '坚持运动,促进代谢', '保持心情舒畅']
},
shire: {
description: '湿热内蕴,面垢油光,口苦口干,大便黏滞',
features: ['面部油光', '口苦口干', '大便黏滞', '易生痤疮'],
suggestions: ['饮食清淡,宜食苦瓜', '避免湿热环境', '适当运动,出汗排湿', '保持平和心态']
},
xueyu: {
description: '血行不畅,肤色晦暗,易生斑点,健忘',
features: ['肤色晦暗', '易生斑点', '健忘', '唇色偏暗'],
suggestions: ['宜食活血化瘀食物', '避免久坐,适当活动', '坚持有氧运动', '保持心情愉快']
},
qiyu: {
description: '气机郁滞,情绪低落,多愁善感,胸闷',
features: ['情绪低落', '多愁善感', '胸闷', '善太息'],
suggestions: ['宜食行气解郁食物', '多参加社交活动', '宜户外运动,舒展身心', '培养兴趣爱好']
},
tebing: {
description: '先天失常,过敏体质,易打喷嚏,皮肤易过敏',
features: ['易过敏', '易打喷嚏', '皮肤敏感', '适应力差'],
suggestions: ['避免接触过敏原', '饮食清淡,避免过敏食物', '适度运动,增强体质', '保持心态平和']
}
}
// 问卷选项
const questionOptions = [
{ value: 1, label: '没有' },
{ value: 2, label: '很少' },
{ value: 3, label: '有时' },
{ value: 4, label: '经常' },
{ value: 5, label: '总是' }
]
// 体质问卷题目(简化版 20 题用于演示)
export const constitutionQuestions: ConstitutionQuestion[] = [
{ id: 1, constitutionType: 'pinghe', question: '您精力充沛吗?', options: questionOptions },
{ id: 2, constitutionType: 'pinghe', question: '您容易疲乏吗?', options: questionOptions },
{ id: 3, constitutionType: 'qixu', question: '您容易气短吗?', options: questionOptions },
{ id: 4, constitutionType: 'qixu', question: '您比别人容易感冒吗?', options: questionOptions },
{ id: 5, constitutionType: 'yangxu', question: '您手脚发凉吗?', options: questionOptions },
{ id: 6, constitutionType: 'yangxu', question: '您比一般人怕冷吗?', options: questionOptions },
{ id: 7, constitutionType: 'yinxu', question: '您感到手脚心发热吗?', options: questionOptions },
{ id: 8, constitutionType: 'yinxu', question: '您口干咽燥吗?', options: questionOptions },
{ id: 9, constitutionType: 'tanshi', question: '您感到身体沉重吗?', options: questionOptions },
{ id: 10, constitutionType: 'tanshi', question: '您腹部肥满松软吗?', options: questionOptions },
{ id: 11, constitutionType: 'shire', question: '您面部油腻吗?', options: questionOptions },
{ id: 12, constitutionType: 'shire', question: '您感到口苦吗?', options: questionOptions },
{ id: 13, constitutionType: 'xueyu', question: '您皮肤易出现瘀斑吗?', options: questionOptions },
{ id: 14, constitutionType: 'xueyu', question: '您容易健忘吗?', options: questionOptions },
{ id: 15, constitutionType: 'qiyu', question: '您感到闷闷不乐吗?', options: questionOptions },
{ id: 16, constitutionType: 'qiyu', question: '您精神紧张焦虑吗?', options: questionOptions },
{ id: 17, constitutionType: 'tebing', question: '您没感冒也会打喷嚏吗?', options: questionOptions },
{ id: 18, constitutionType: 'tebing', question: '您容易过敏吗?', options: questionOptions },
{ id: 19, constitutionType: 'pinghe', question: '您睡眠质量好吗?', options: questionOptions },
{ id: 20, constitutionType: 'qixu', question: '您活动后容易出虚汗吗?', options: questionOptions }
]
// 计算体质结果
export function calculateConstitution(answers: Record<number, number>): ConstitutionResult {
const types: ConstitutionType[] = ['pinghe', 'qixu', 'yangxu', 'yinxu', 'tanshi', 'shire', 'xueyu', 'qiyu', 'tebing']
// 统计各体质得分
const typeScores: Record<ConstitutionType, { total: number; count: number }> = {} 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<ConstitutionType, number> = {} 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
types.forEach(type => {
if (type !== 'pinghe' && scores[type] > maxScore) {
maxScore = scores[type]
primaryType = type
}
})
// 如果其他体质得分都很低,判定为平和质
if (maxScore < 30 && scores.pinghe >= 50) {
primaryType = 'pinghe'
}
const info = constitutionDescriptions[primaryType]
return {
primaryType,
scores,
description: info.description,
suggestions: info.suggestions,
assessedAt: new Date().toISOString()
}
}

4
app/src/mock/index.ts

@ -0,0 +1,4 @@
export * from './user'
export * from './constitution'
export * from './chat'
export * from './products'

38
app/src/mock/medication.ts

@ -0,0 +1,38 @@
// 模拟用药记录数据
export interface MedicationRecord {
id: string
name: string
dosage: string
frequency: string
startDate: string
endDate?: string
notes?: string
}
export const mockMedicationRecords: MedicationRecord[] = [
{
id: '1',
name: '黄芪精口服液',
dosage: '每次1支',
frequency: '每日2次',
startDate: '2026-01-15',
notes: '饭后服用'
},
{
id: '2',
name: '六味地黄丸',
dosage: '每次8粒',
frequency: '每日3次',
startDate: '2026-01-20',
notes: '滋阴补肾'
},
{
id: '3',
name: '复方丹参片',
dosage: '每次3片',
frequency: '每日3次',
startDate: '2025-12-01',
endDate: '2026-01-15',
notes: '活血化瘀'
}
]

94
app/src/mock/news.ts

@ -0,0 +1,94 @@
// 模拟健康资讯数据
export interface HealthNews {
id: number
title: string
summary: string
category: string
icon: string
publishTime: string
readCount: number
content: string
}
export const mockHealthNews: HealthNews[] = [
{
id: 1,
title: '春季养生:如何调理气虚体质',
summary: '春季是调理体质的好时机,气虚质人群应注意保暖、适当运动...',
category: '养生',
icon: '🌸',
publishTime: '2026-02-01',
readCount: 3256,
content: '春季万物复苏,是调理体质的好时机。气虚质人群常表现为容易疲劳、气短懒言、易出汗等症状。\n\n【调理建议】\n1. 饮食调养:多吃黄芪、人参、山药等益气食物\n2. 起居有常:保证充足睡眠,避免过度劳累\n3. 适量运动:太极拳、八段锦等柔和运动\n4. 情志调摄:保持乐观心态,避免过度思虑'
},
{
id: 2,
title: '中老年人血压管理的五个要点',
summary: '高血压是中老年人常见慢性病,科学管理血压对健康至关重要...',
category: '健康',
icon: '❤️',
publishTime: '2026-01-30',
readCount: 5621,
content: '高血压是中老年人最常见的慢性疾病之一,长期血压控制不良会增加心脑血管疾病风险。\n\n【五个管理要点】\n1. 定期监测:每天早晚各测一次血压\n2. 低盐饮食:每日盐摄入不超过6克\n3. 规律用药:遵医嘱服药,不擅自停药\n4. 适量运动:每周至少150分钟中等强度运动\n5. 情绪管理:避免情绪激动和精神紧张'
},
{
id: 3,
title: '改善睡眠质量的中医调理方法',
summary: '失眠困扰着很多人,中医认为失眠与心、肝、脾、肾密切相关...',
category: '睡眠',
icon: '🌙',
publishTime: '2026-01-28',
readCount: 4102,
content: '失眠是现代人常见的健康问题,中医认为失眠多与心神不宁、肝气郁结、脾胃不和有关。\n\n【中医调理方法】\n1. 睡前泡脚:用温水泡脚15-20分钟\n2. 穴位按摩:按揉安眠穴、神门穴\n3. 食疗助眠:酸枣仁、百合、莲子等\n4. 香薰疗法:薰衣草、檀香等安神精油\n5. 作息规律:固定时间入睡和起床'
},
{
id: 4,
title: '膳食纤维对肠道健康的重要性',
summary: '膳食纤维被称为"第七营养素",对维护肠道健康非常重要...',
category: '饮食',
icon: '🥗',
publishTime: '2026-01-25',
readCount: 2890,
content: '膳食纤维是人体必需的营养物质,虽然不能被消化吸收,但对肠道健康至关重要。\n\n【膳食纤维的好处】\n1. 促进肠道蠕动,预防便秘\n2. 调节肠道菌群平衡\n3. 延缓血糖上升\n4. 增加饱腹感,控制体重\n\n【富含膳食纤维的食物】\n全谷物、豆类、蔬菜、水果、坚果等'
},
{
id: 5,
title: '老年人如何预防跌倒',
summary: '跌倒是老年人常见的意外伤害,科学预防可大大降低风险...',
category: '安全',
icon: '🦶',
publishTime: '2026-01-22',
readCount: 3567,
content: '跌倒是65岁以上老年人意外伤害的首要原因,预防跌倒非常重要。\n\n【预防措施】\n1. 家居环境:保持地面干燥、清除障碍物\n2. 辅助工具:必要时使用拐杖或助行器\n3. 运动锻炼:加强下肢力量和平衡训练\n4. 定期检查:检查视力、服用药物副作用\n5. 穿戴合适:选择防滑鞋,避免穿长裤'
},
{
id: 6,
title: '糖尿病患者的饮食原则',
summary: '合理的饮食是糖尿病管理的基础,掌握饮食原则很重要...',
category: '饮食',
icon: '🍎',
publishTime: '2026-01-20',
readCount: 6234,
content: '糖尿病患者的饮食管理是血糖控制的基础。\n\n【饮食原则】\n1. 控制总热量:根据体重计算每日热量\n2. 均衡营养:碳水化合物、蛋白质、脂肪合理搭配\n3. 定时定量:三餐规律,避免暴饮暴食\n4. 选择低GI食物:粗粮代替精细主食\n5. 多吃蔬菜:每日500克以上\n6. 限制甜食:避免含糖饮料和甜品'
},
{
id: 7,
title: '冬季养护关节的实用方法',
summary: '冬季寒冷,关节疾病容易发作或加重,做好保暖防护很重要...',
category: '养生',
icon: '🦴',
publishTime: '2026-01-18',
readCount: 2456,
content: '冬季是关节疾病的高发季节,做好关节养护很重要。\n\n【养护方法】\n1. 注意保暖:戴护膝、护腕\n2. 适量运动:游泳、骑车等低冲击运动\n3. 控制体重:减轻关节负担\n4. 补充营养:钙、维生素D、软骨素\n5. 热敷理疗:促进血液循环\n6. 避免受凉:不要久坐冷板凳'
},
{
id: 8,
title: '中医教你认识九种体质',
summary: '中医体质学说将人分为九种体质类型,了解自己的体质有助于养生...',
category: '中医',
icon: '📚',
publishTime: '2026-01-15',
readCount: 4521,
content: '中医体质学说将人的体质分为九种类型:\n\n1. 平和质:阴阳调和,身体健康\n2. 气虚质:容易疲劳,气短懒言\n3. 阳虚质:畏寒怕冷,手脚冰凉\n4. 阴虚质:口干咽燥,手足心热\n5. 痰湿质:形体肥胖,痰多困倦\n6. 湿热质:面垢油光,口苦口干\n7. 血瘀质:肤色晦暗,易生斑点\n8. 气郁质:情绪低落,善太息\n9. 特禀质:过敏体质,适应力差\n\n了解自己的体质类型,可以更有针对性地进行养生调理。'
}
]

32
app/src/mock/products.ts

@ -0,0 +1,32 @@
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: 268, imageUrl: '', mallUrl: 'https://mall.example.com/product/3' },
{ id: 4, name: '枸杞原浆', category: '滋阴类', description: '滋补肝肾', efficacy: '滋补肝肾,明目润肺', suitable: '阴虚质', price: 158, imageUrl: '', mallUrl: 'https://mall.example.com/product/4' },
{ id: 5, name: '红豆薏米粉', category: '祛湿类', description: '健脾祛湿', efficacy: '健脾祛湿,消肿利水', suitable: '痰湿质', price: 39, imageUrl: '', mallUrl: 'https://mall.example.com/product/5' },
{ id: 6, name: '三七粉', category: '活血类', description: '活血化瘀', efficacy: '活血化瘀,消肿止痛', suitable: '血瘀质', price: 128, imageUrl: '', mallUrl: 'https://mall.example.com/product/6' },
{ id: 7, name: '玫瑰花茶', category: '理气类', description: '疏肝理气', efficacy: '疏肝理气,美容养颜', suitable: '气郁质', price: 38, imageUrl: '', mallUrl: 'https://mall.example.com/product/7' },
{ id: 8, name: '益生菌粉', category: '抗敏类', description: '调节肠道', efficacy: '调节肠道,增强免疫', suitable: '特禀质', price: 98, imageUrl: '', mallUrl: 'https://mall.example.com/product/8' }
]
// 体质-产品关联
const constitutionProductMap: Record<ConstitutionType, number[]> = {
pinghe: [1, 2],
qixu: [1, 2],
yangxu: [3],
yinxu: [4],
tanshi: [5],
shire: [5],
xueyu: [6],
qiyu: [7],
tebing: [8]
}
// 根据体质获取推荐产品
export function getProductsByConstitution(type: ConstitutionType): Product[] {
const productIds = constitutionProductMap[type] || []
return mockProducts.filter(p => productIds.includes(p.id))
}

23
app/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<User | null> {
return new Promise((resolve) => {
setTimeout(() => {
if (phone === '13800138000' && code === '123456') {
resolve(mockUser)
} else {
resolve(null)
}
}, 800)
})
}

183
app/src/navigation/index.tsx

@ -0,0 +1,183 @@
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useAuthStore } from "../stores/authStore";
import { useSettingsStore, getFontSize } from "../stores/settingsStore";
import { colors } from "../theme";
// Screens
import LoginScreen from "../screens/auth/LoginScreen";
import HomeScreen from "../screens/home/HomeScreen";
import ChatListScreen from "../screens/chat/ChatListScreen";
import ChatDetailScreen from "../screens/chat/ChatDetailScreen";
import ConstitutionHomeScreen from "../screens/constitution/ConstitutionHomeScreen";
import ConstitutionTestScreen from "../screens/constitution/ConstitutionTestScreen";
import ConstitutionResultScreen from "../screens/constitution/ConstitutionResultScreen";
import ProfileScreen from "../screens/profile/ProfileScreen";
import HealthProfileScreen from "../screens/profile/HealthProfileScreen";
import HealthNewsScreen from "../screens/news/HealthNewsScreen";
// Icons (using text for simplicity)
import { Text, View } from "react-native";
const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
const ChatStack = createNativeStackNavigator();
const ConstitutionStack = createNativeStackNavigator();
const HomeStack = createNativeStackNavigator();
const ProfileStack = createNativeStackNavigator();
function TabIcon({
label,
focused,
elderMode,
}: {
label: string;
focused: boolean;
elderMode: boolean;
}) {
const icons: Record<string, string> = {
: "🏠",
: "💬",
: "📊",
: "👤",
};
const fontSize = elderMode ? 24 : 20;
const textSize = elderMode ? 12 : 10;
return (
<View style={{ alignItems: "center" }}>
<Text style={{ fontSize }}>{icons[label]}</Text>
<Text
style={{
fontSize: textSize,
color: focused ? colors.primary : colors.textHint,
marginTop: 2,
}}
>
{label}
</Text>
</View>
);
}
function HomeStackNavigator() {
return (
<HomeStack.Navigator screenOptions={{ headerShown: false }}>
<HomeStack.Screen name="HomeMain" component={HomeScreen} />
<HomeStack.Screen name="HealthNews" component={HealthNewsScreen} />
</HomeStack.Navigator>
);
}
function ChatStackNavigator() {
return (
<ChatStack.Navigator screenOptions={{ headerShown: false }}>
<ChatStack.Screen name="ChatList" component={ChatListScreen} />
<ChatStack.Screen name="ChatDetail" component={ChatDetailScreen} />
</ChatStack.Navigator>
);
}
function ConstitutionStackNavigator() {
return (
<ConstitutionStack.Navigator screenOptions={{ headerShown: false }}>
<ConstitutionStack.Screen
name="ConstitutionHome"
component={ConstitutionHomeScreen}
/>
<ConstitutionStack.Screen
name="ConstitutionTest"
component={ConstitutionTestScreen}
/>
<ConstitutionStack.Screen
name="ConstitutionResult"
component={ConstitutionResultScreen}
/>
</ConstitutionStack.Navigator>
);
}
function ProfileStackNavigator() {
return (
<ProfileStack.Navigator screenOptions={{ headerShown: false }}>
<ProfileStack.Screen name="ProfileMain" component={ProfileScreen} />
<ProfileStack.Screen
name="HealthProfile"
component={HealthProfileScreen}
/>
</ProfileStack.Navigator>
);
}
function MainTabs() {
const { elderMode } = useSettingsStore();
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarShowLabel: false,
tabBarStyle: {
height: elderMode ? 70 : 60,
paddingBottom: elderMode ? 10 : 8,
paddingTop: elderMode ? 10 : 8,
},
}}
>
<Tab.Screen
name="Home"
component={HomeStackNavigator}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon label="首页" focused={focused} elderMode={elderMode} />
),
}}
/>
<Tab.Screen
name="Chat"
component={ChatStackNavigator}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon label="问答" focused={focused} elderMode={elderMode} />
),
}}
/>
<Tab.Screen
name="Constitution"
component={ConstitutionStackNavigator}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon label="体质" focused={focused} elderMode={elderMode} />
),
}}
/>
<Tab.Screen
name="Profile"
component={ProfileStackNavigator}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon label="我的" focused={focused} elderMode={elderMode} />
),
}}
/>
</Tab.Navigator>
);
}
export default function Navigation() {
const { isLoggedIn } = useAuthStore();
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{!isLoggedIn ? (
<Stack.Screen name="Login" component={LoginScreen} />
) : (
<Stack.Screen name="Main" component={MainTabs} />
)}
</Stack.Navigator>
</NavigationContainer>
);
}

324
app/src/screens/auth/LoginScreen.tsx

@ -0,0 +1,324 @@
import React, { useState } from 'react'
import { View, StyleSheet, Text, KeyboardAvoidingView, Platform, ScrollView, TouchableOpacity } from 'react-native'
import { TextInput, Button } from 'react-native-paper'
import { useAuthStore } from '../../stores/authStore'
import { useSettingsStore, getFontSize } from '../../stores/settingsStore'
import { useAlert } from '../../components'
import { colors } from '../../theme'
export default function LoginScreen() {
const [phone, setPhone] = useState('')
const [code, setCode] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [countdown, setCountdown] = useState(0)
const { login, sendCode, isLoading, error, clearError } = useAuthStore()
const { elderMode } = useSettingsStore()
const { showToast, showAlert } = useAlert()
const fontSize = (base: number) => getFontSize(elderMode, base)
const handleSendCode = async () => {
if (!/^1\d{10}$/.test(phone)) {
showToast('请输入正确的手机号')
return
}
const success = await sendCode(phone, 'login')
if (success) {
setCountdown(60)
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
showToast('验证码已发送')
} else {
showAlert('发送失败', error || '请稍后重试')
clearError()
}
}
const handleLogin = async () => {
if (!/^1\d{10}$/.test(phone)) {
showToast('请输入正确的手机号')
return
}
if (code.length < 1) {
showToast('请输入密码或验证码')
return
}
const result = await login(phone, code)
if (!result.success) {
showAlert('登录失败', result.error || '用户名或密码错误')
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* 顶部绿色区域 */}
<View style={[styles.header, elderMode && styles.headerElder]}>
<View style={[styles.logoContainer, elderMode && styles.logoContainerElder]}>
<Text style={[styles.logoIcon, { fontSize: fontSize(48) }]}>🏥</Text>
<View style={styles.logoCross}>
<Text style={{ fontSize: fontSize(12) }}></Text>
</View>
</View>
<Text style={[styles.welcomeText, { fontSize: fontSize(24) }]}>AI健康助手</Text>
</View>
{/* 白色表单卡片 */}
<View style={styles.formCard}>
<View style={styles.cardHandle} />
{/* 手机号 */}
<View style={styles.inputGroup}>
<Text style={[styles.inputLabel, { fontSize: fontSize(14) }]}>
<Text style={styles.required}>*</Text>
</Text>
<View style={[styles.inputWrapper, elderMode && styles.inputWrapperElder]}>
<Text style={[styles.inputIcon, { fontSize: fontSize(18) }]}>👤</Text>
<TextInput
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
maxLength={11}
mode="flat"
style={[styles.input, { fontSize: fontSize(15) }]}
placeholder="请输入手机号"
placeholderTextColor="#9CA3AF"
underlineColor="transparent"
activeUnderlineColor="transparent"
/>
</View>
</View>
{/* 验证码 */}
<View style={styles.inputGroup}>
<Text style={[styles.inputLabel, { fontSize: fontSize(14) }]}>
<Text style={styles.required}>*</Text>
</Text>
<View style={[styles.inputWrapper, elderMode && styles.inputWrapperElder]}>
<Text style={[styles.inputIcon, { fontSize: fontSize(18) }]}>🔒</Text>
<TextInput
value={code}
onChangeText={setCode}
keyboardType="number-pad"
maxLength={6}
mode="flat"
style={[styles.input, { fontSize: fontSize(15) }]}
placeholder="请输入验证码"
placeholderTextColor="#9CA3AF"
underlineColor="transparent"
activeUnderlineColor="transparent"
secureTextEntry={!showPassword}
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
<Text style={{ fontSize: fontSize(18), opacity: 0.5 }}>{showPassword ? '👁️' : '👁️‍🗨️'}</Text>
</TouchableOpacity>
</View>
</View>
{/* 获取验证码 */}
<TouchableOpacity
style={styles.forgotLink}
onPress={handleSendCode}
disabled={countdown > 0 || isLoading}
>
<Text style={[styles.forgotText, { fontSize: fontSize(14) }]}>
{countdown > 0 ? `${countdown}s后重新获取` : '获取验证码'}
</Text>
</TouchableOpacity>
{/* 登录按钮 */}
<TouchableOpacity
style={[styles.loginBtn, isLoading && styles.loginBtnDisabled, elderMode && styles.loginBtnElder]}
onPress={handleLogin}
disabled={isLoading}
>
<Text style={[styles.loginBtnText, { fontSize: fontSize(16) }]}>{isLoading ? '登录中...' : '登录'}</Text>
</TouchableOpacity>
{/* 注册链接 */}
<View style={styles.registerRow}>
<Text style={[styles.registerText, { fontSize: fontSize(14) }]}></Text>
<TouchableOpacity>
<Text style={[styles.registerLink, { fontSize: fontSize(14) }]}></Text>
</TouchableOpacity>
</View>
</View>
{/* 底部协议 */}
<View style={styles.footer}>
<TouchableOpacity>
<Text style={[styles.link, { fontSize: fontSize(13) }]}></Text>
</TouchableOpacity>
<TouchableOpacity>
<Text style={[styles.link, { fontSize: fontSize(13) }]}></Text>
</TouchableOpacity>
<Text style={[styles.agreementText, { fontSize: fontSize(13) }]}></Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.primary
},
scrollContent: {
flexGrow: 1
},
header: {
alignItems: 'center',
paddingTop: 60,
paddingBottom: 40,
backgroundColor: colors.primary
},
headerElder: {
paddingTop: 70,
paddingBottom: 50
},
logoContainer: {
width: 100,
height: 100,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 50,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
position: 'relative'
},
logoContainerElder: {
width: 120,
height: 120,
borderRadius: 60
},
logoIcon: {},
logoCross: {
position: 'absolute',
top: 8,
right: 8,
width: 24,
height: 24,
backgroundColor: '#FCD34D',
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center'
},
welcomeText: {
fontWeight: '600',
color: '#fff'
},
formCard: {
flex: 1,
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 24
},
cardHandle: {
width: 40,
height: 4,
backgroundColor: '#E5E7EB',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 24
},
inputGroup: {
marginBottom: 20
},
inputLabel: {
fontWeight: '500',
color: '#374151',
marginBottom: 8
},
required: {
color: '#EF4444'
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F3F4F6',
borderRadius: 24,
paddingHorizontal: 16,
height: 52
},
inputWrapperElder: {
height: 60
},
inputIcon: {
marginRight: 12,
opacity: 0.5
},
input: {
flex: 1,
backgroundColor: 'transparent',
height: 52
},
forgotLink: {
alignSelf: 'flex-end',
marginBottom: 24
},
forgotText: {
color: colors.primary
},
loginBtn: {
backgroundColor: colors.primary,
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20
},
loginBtnElder: {
height: 60
},
loginBtnDisabled: {
opacity: 0.7
},
loginBtnText: {
color: '#fff',
fontWeight: '600'
},
registerRow: {
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 16
},
registerText: {
color: '#6B7280'
},
registerLink: {
color: colors.primary,
fontWeight: '500'
},
footer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
paddingVertical: 16,
paddingHorizontal: 24,
backgroundColor: '#fff'
},
link: {
color: colors.primary,
marginRight: 8
},
agreementText: {
color: '#6B7280'
}
})

883
app/src/screens/chat/ChatDetailScreen.tsx

@ -0,0 +1,883 @@
import React, { useState, useRef, useEffect } from "react";
import {
View,
StyleSheet,
ScrollView,
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
Pressable,
Animated,
} from "react-native";
import { useRoute, useNavigation } from "@react-navigation/native";
import { Portal, Modal, IconButton } from "react-native-paper";
import { useChatStore } from "../../stores/chatStore";
import { useSettingsStore, getFontSize } from "../../stores/settingsStore";
import { useAlert } from "../../components";
import { colors } from "../../theme";
import type { Message } from "../../types";
// 加载动画组件 - 三个点的脉冲动画
const LoadingDots = ({
text = "思考中",
fontSize = 15,
}: {
text?: string;
fontSize?: number;
}) => {
const [dotCount, setDotCount] = useState(1);
useEffect(() => {
const interval = setInterval(() => {
setDotCount((prev) => (prev >= 3 ? 1 : prev + 1));
}, 400);
return () => clearInterval(interval);
}, []);
const dots = ".".repeat(dotCount);
return (
<Text style={{ fontSize, color: colors.textSecondary }}>
{text}
{dots}
</Text>
);
};
// 思考过程手风琴组件
const ThinkingAccordion = ({
thinking,
isThinking,
fontSize,
}: {
thinking?: string;
isThinking?: boolean;
fontSize: (base: number) => number;
}) => {
const [expanded, setExpanded] = useState(true); // 默认展开
const scrollViewRef = useRef<ScrollView>(null);
// 当思考结束且有内容时,自动折叠
useEffect(() => {
if (!isThinking && thinking) {
// 延迟折叠,让用户看到思考完成
const timer = setTimeout(() => setExpanded(false), 500);
return () => clearTimeout(timer);
}
}, [isThinking, thinking]);
// 思考内容更新时自动滚动到底部
useEffect(() => {
if (thinking && expanded && scrollViewRef.current) {
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 50);
}
}, [thinking, expanded]);
// 如果没有思考状态且没有内容,不显示
if (!isThinking && !thinking) {
return null;
}
return (
<View style={accordionStyles.container}>
<TouchableOpacity
style={accordionStyles.header}
onPress={() => !isThinking && setExpanded(!expanded)}
activeOpacity={isThinking ? 1 : 0.7}
>
<View style={accordionStyles.headerContent}>
{isThinking ? (
<Text style={[accordionStyles.label, { fontSize: fontSize(12) }]}>
{"💭 "}
<LoadingDots text="思考中" fontSize={fontSize(12)} />
</Text>
) : (
<Text style={[accordionStyles.label, { fontSize: fontSize(12) }]}>
💭 {expanded ? "收起" : "展开"}
</Text>
)}
{!isThinking && (
<Text style={accordionStyles.arrow}>{expanded ? "▲" : "▼"}</Text>
)}
</View>
</TouchableOpacity>
{expanded && thinking && (
<ScrollView
ref={scrollViewRef}
style={accordionStyles.scrollView}
nestedScrollEnabled={true}
showsVerticalScrollIndicator={true}
>
<Text style={[accordionStyles.text, { fontSize: fontSize(12) }]}>
{thinking}
</Text>
</ScrollView>
)}
</View>
);
};
// 手风琴组件样式
const accordionStyles = StyleSheet.create({
container: {
backgroundColor: "#f5f9fa",
borderRadius: 8,
marginBottom: 10,
borderLeftWidth: 3,
borderLeftColor: "#5cb3cc",
overflow: "hidden",
},
header: {
padding: 10,
},
headerContent: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
label: {
color: "#5cb3cc",
fontWeight: "600",
},
arrow: {
color: "#5cb3cc",
fontSize: 10,
marginLeft: 8,
},
scrollView: {
maxHeight: 100,
paddingHorizontal: 10,
paddingBottom: 10,
},
text: {
color: "#888",
lineHeight: 18,
},
});
export default function ChatDetailScreen() {
const route = useRoute<any>();
const navigation = useNavigation<any>();
const { id } = route.params;
const {
conversations,
messages,
fetchMessages,
sendMessage,
deleteConversation,
createConversation,
isSending,
} = useChatStore();
const { elderMode } = useSettingsStore();
const { showAlert, showToast } = useAlert();
const [input, setInput] = useState("");
const [showHistory, setShowHistory] = useState(false);
const [isVoiceMode, setIsVoiceMode] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const scrollRef = useRef<ScrollView>(null);
const fontSize = (base: number) => getFontSize(elderMode, base);
// 加载消息
useEffect(() => {
fetchMessages(id);
}, [id]);
// 滚动到底部
useEffect(() => {
setTimeout(() => scrollRef.current?.scrollToEnd(), 100);
}, [messages]);
const quickQuestions = [
"我最近总是感觉疲劳怎么办?",
"如何改善睡眠质量?",
"感冒了应该注意什么?",
];
const handleSend = async (text?: string) => {
const content = (text || input).trim();
if (!content || isSending) return;
setInput("");
scrollRef.current?.scrollToEnd();
await sendMessage(id, content, (chunk) => {
// 流式响应回调
scrollRef.current?.scrollToEnd();
});
};
const handleNewChat = async () => {
const newConv = await createConversation("新对话");
if (newConv) {
setShowHistory(false);
navigation.replace("ChatDetail", { id: newConv.id });
}
};
const handleDeleteConv = (convId: string) => {
// 先关闭 Modal,然后延迟显示确认弹窗
setShowHistory(false);
// 直接使用 setTimeout 延迟显示确认弹窗
setTimeout(() => {
showAlert("确认删除", "确定要删除这个对话吗?", [
{ text: "取消", style: "cancel" },
{
text: "删除",
style: "destructive",
onPress: async () => {
const success = await deleteConversation(convId);
if (success) {
showToast("删除成功");
if (convId === id) {
if (conversations.length > 1) {
const next = conversations.find((c) => c.id !== convId);
if (next) navigation.replace("ChatDetail", { id: next.id });
} else {
navigation.goBack();
}
}
} else {
showToast("删除失败,请重试");
}
},
},
]);
}, 350);
};
// 图片上传(仅展示)
const handleImageUpload = () => {
showAlert("上传图片", "请选择图片来源", [
{ text: "拍照", onPress: () => showToast("拍照功能开发中") },
{ text: "从相册选择", onPress: () => showToast("相册功能开发中") },
{ text: "取消", style: "cancel" },
]);
};
// 语音录制(仅展示)
const handleVoicePressIn = () => {
setIsRecording(true);
};
const handleVoicePressOut = () => {
setIsRecording(false);
showToast("语音识别功能开发中,敬请期待!");
};
const formatTime = (time: string) => {
const date = new Date(time);
return `${date.getHours().toString().padStart(2, "0")}:${date
.getMinutes()
.toString()
.padStart(2, "0")}`;
};
const formatDate = (time: string) => {
const date = new Date(time);
const now = new Date();
if (date.toDateString() === now.toDateString()) return "今天";
return `${date.getMonth() + 1}/${date.getDate()}`;
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.header}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backBtn}
>
<Text style={[styles.backText, { fontSize: fontSize(20) }]}></Text>
</TouchableOpacity>
<Text style={[styles.title, { fontSize: fontSize(16) }]}>
AI健康助手
</Text>
<TouchableOpacity
onPress={() => setShowHistory(true)}
style={styles.historyBtn}
>
<Text style={[styles.historyText, { fontSize: fontSize(13) }]}>
</Text>
</TouchableOpacity>
</View>
<ScrollView
ref={scrollRef}
style={styles.messageList}
contentContainerStyle={styles.messageContent}
>
{messages.length === 0 && (
<>
<View style={styles.welcome}>
<View
style={[
styles.welcomeIcon,
elderMode && styles.welcomeIconElder,
]}
>
<Text style={{ fontSize: fontSize(28) }}>🤖</Text>
</View>
<View style={styles.welcomeText}>
<Text style={[styles.welcomeTitle, { fontSize: fontSize(16) }]}>
AI健康助手
</Text>
<Text style={[styles.welcomeDesc, { fontSize: fontSize(14) }]}>
AI健康助手
</Text>
</View>
</View>
<View style={styles.quickQuestions}>
<Text style={[styles.quickLabel, { fontSize: fontSize(14) }]}>
</Text>
{quickQuestions.map((q, i) => (
<TouchableOpacity
key={i}
style={styles.quickBtn}
onPress={() => handleSend(q)}
>
<Text style={[styles.quickText, { fontSize: fontSize(14) }]}>
{q}
</Text>
</TouchableOpacity>
))}
</View>
</>
)}
{messages.map((msg) => (
<View
key={msg.id}
style={[
styles.messageItem,
msg.role === "user" && styles.userMessage,
]}
>
<View
style={[
styles.avatar,
msg.role === "user" ? styles.userAvatar : styles.aiAvatar,
elderMode && styles.avatarElder,
]}
>
<Text style={{ fontSize: fontSize(16) }}>
{msg.role === "user" ? "👤" : "🤖"}
</Text>
</View>
<View
style={[
styles.bubble,
msg.role === "user" ? styles.userBubble : styles.aiBubble,
]}
>
{/* 思考过程显示 - 手风琴样式 */}
<ThinkingAccordion
thinking={msg.thinking}
isThinking={msg.isThinking}
fontSize={fontSize}
/>
{/* 正式回答内容 */}
{msg.content ? (
<Text
style={[
styles.messageText,
msg.role === "user" && styles.userText,
{ fontSize: fontSize(15) },
]}
>
{msg.content}
</Text>
) : isSending && msg.role === "assistant" && !msg.isThinking ? (
<LoadingDots text="AI 正在回复" fontSize={fontSize(15)} />
) : null}
<Text
style={[
styles.messageTime,
msg.role === "user" && styles.userTime,
{ fontSize: fontSize(10) },
]}
>
{formatTime(msg.createdAt)}
</Text>
</View>
</View>
))}
</ScrollView>
{/* 输入区域 */}
<View style={[styles.inputArea, elderMode && styles.inputAreaElder]}>
{/* 语音/文字切换按钮 */}
<TouchableOpacity
style={[styles.modeBtn, elderMode && styles.modeBtnElder]}
onPress={() => setIsVoiceMode(!isVoiceMode)}
>
<Text style={{ fontSize: fontSize(20) }}>
{isVoiceMode ? "⌨️" : "🎤"}
</Text>
</TouchableOpacity>
{/* 输入区域:文字模式或语音模式 */}
{isVoiceMode ? (
<Pressable
style={[
styles.voiceBtn,
elderMode && styles.voiceBtnElder,
isRecording && styles.voiceBtnRecording,
]}
onPressIn={handleVoicePressIn}
onPressOut={handleVoicePressOut}
>
<Text
style={[
styles.voiceBtnText,
{ fontSize: fontSize(15) },
isRecording && styles.voiceBtnTextRecording,
]}
>
{isRecording ? "松开 结束" : "按住 说话"}
</Text>
</Pressable>
) : (
<TextInput
style={[
styles.input,
{ fontSize: fontSize(15) },
elderMode && styles.inputElder,
]}
value={input}
onChangeText={setInput}
placeholder="请输入您的健康问题..."
multiline
editable={!isSending}
/>
)}
{/* 图片按钮 */}
<TouchableOpacity
style={[styles.mediaBtn, elderMode && styles.mediaBtnElder]}
onPress={handleImageUpload}
>
<Text style={{ fontSize: fontSize(20) }}>📷</Text>
</TouchableOpacity>
{/* 发送按钮 - 只在文字模式且有内容时显示 */}
{!isVoiceMode && input.trim().length > 0 ? (
<TouchableOpacity
style={[
styles.sendBtn,
isSending && styles.sendBtnDisabled,
elderMode && styles.sendBtnElder,
]}
onPress={() => handleSend()}
disabled={isSending}
>
<Text style={[styles.sendText, { fontSize: fontSize(14) }]}>
</Text>
</TouchableOpacity>
) : null}
</View>
<View style={styles.disclaimer}>
<Text style={[styles.disclaimerText, { fontSize: fontSize(11) }]}>
AI
</Text>
</View>
{/* 对话管理弹窗 */}
<Portal>
<Modal
visible={showHistory}
onDismiss={() => setShowHistory(false)}
contentContainerStyle={styles.modal}
>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { fontSize: fontSize(18) }]}>
</Text>
<IconButton
icon="close"
size={elderMode ? 24 : 20}
onPress={() => setShowHistory(false)}
/>
</View>
<ScrollView style={styles.modalList}>
{conversations.map((conv) => (
<TouchableOpacity
key={conv.id}
style={[
styles.historyItem,
conv.id === id && styles.historyItemActive,
]}
onPress={() => {
setShowHistory(false);
if (conv.id !== id) {
navigation.replace("ChatDetail", { id: conv.id });
}
}}
>
<View
style={[
styles.historyIcon,
elderMode && styles.historyIconElder,
]}
>
<Text style={{ fontSize: fontSize(16) }}>💬</Text>
</View>
<View style={styles.historyInfo}>
<Text
style={[styles.historyTitle, { fontSize: fontSize(14) }]}
numberOfLines={1}
>
{conv.title}
</Text>
<Text
style={[styles.historyTime, { fontSize: fontSize(11) }]}
>
{formatDate(conv.updatedAt)}
</Text>
</View>
<TouchableOpacity
style={styles.deleteBtn}
onPress={() => handleDeleteConv(conv.id)}
>
<Text style={{ fontSize: fontSize(16) }}>🗑</Text>
</TouchableOpacity>
</TouchableOpacity>
))}
</ScrollView>
<TouchableOpacity
style={[styles.newChatBtn, elderMode && styles.newChatBtnElder]}
onPress={handleNewChat}
>
<Text style={[styles.newChatText, { fontSize: fontSize(15) }]}>
+
</Text>
</TouchableOpacity>
</Modal>
</Portal>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
paddingTop: 50,
backgroundColor: colors.surface,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
backBtn: {
width: 40,
},
backText: {
color: colors.primary,
},
title: {
fontWeight: "600",
color: colors.textPrimary,
},
historyBtn: {
paddingHorizontal: 12,
paddingVertical: 4,
backgroundColor: colors.primaryLight,
borderRadius: 12,
},
historyText: {
color: colors.primary,
},
messageList: {
flex: 1,
},
messageContent: {
padding: 16,
paddingBottom: 80,
},
welcome: {
flexDirection: "row",
marginBottom: 20,
},
welcomeIcon: {
width: 48,
height: 48,
backgroundColor: "#DBEAFE",
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
welcomeIconElder: {
width: 56,
height: 56,
},
welcomeText: {
flex: 1,
marginLeft: 12,
backgroundColor: "#f3f4f6",
padding: 12,
borderRadius: 12,
},
welcomeTitle: {
fontWeight: "600",
marginBottom: 4,
},
welcomeDesc: {
color: colors.textSecondary,
lineHeight: 22,
},
quickQuestions: {
alignItems: "center",
marginBottom: 20,
},
quickLabel: {
color: colors.textHint,
marginBottom: 12,
},
quickBtn: {
backgroundColor: colors.surface,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
marginBottom: 8,
borderWidth: 1,
borderColor: colors.border,
},
quickText: {
color: colors.textSecondary,
},
messageItem: {
flexDirection: "row",
marginBottom: 16,
},
userMessage: {
flexDirection: "row-reverse",
},
avatar: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
avatarElder: {
width: 44,
height: 44,
},
userAvatar: {
backgroundColor: colors.primary,
},
aiAvatar: {
backgroundColor: "#DBEAFE",
},
bubble: {
maxWidth: "70%",
marginHorizontal: 10,
padding: 12,
borderRadius: 16,
},
userBubble: {
backgroundColor: colors.primary,
borderBottomRightRadius: 4,
},
aiBubble: {
backgroundColor: "#f3f4f6",
borderBottomLeftRadius: 4,
},
messageText: {
lineHeight: 24,
color: colors.textPrimary,
},
userText: {
color: "#fff",
},
messageTime: {
color: colors.textHint,
marginTop: 6,
},
userTime: {
color: "rgba(255,255,255,0.7)",
},
inputArea: {
flexDirection: "row",
alignItems: "center",
padding: 12,
backgroundColor: colors.surface,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 8,
},
inputAreaElder: {
padding: 16,
},
modeBtn: {
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.background,
borderRadius: 20,
},
modeBtnElder: {
width: 48,
height: 48,
},
mediaBtn: {
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.background,
borderRadius: 20,
},
mediaBtnElder: {
width: 48,
height: 48,
},
input: {
flex: 1,
backgroundColor: colors.background,
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
maxHeight: 100,
},
inputElder: {
paddingVertical: 14,
},
voiceBtn: {
flex: 1,
height: 44,
backgroundColor: colors.background,
borderRadius: 22,
alignItems: "center",
justifyContent: "center",
},
voiceBtnElder: {
height: 52,
},
voiceBtnRecording: {
backgroundColor: colors.primaryLight,
},
voiceBtnText: {
color: colors.textSecondary,
fontWeight: "500",
},
voiceBtnTextRecording: {
color: colors.primary,
},
sendBtn: {
backgroundColor: colors.primary,
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
},
sendBtnElder: {
paddingHorizontal: 20,
paddingVertical: 14,
},
sendBtnDisabled: {
opacity: 0.5,
},
sendText: {
color: "#fff",
fontWeight: "600",
},
disclaimer: {
backgroundColor: "#fef2f2",
padding: 8,
},
disclaimerText: {
textAlign: "center",
color: colors.danger,
},
modal: {
backgroundColor: colors.surface,
margin: 20,
borderRadius: 16,
maxHeight: "60%",
},
modalHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
paddingRight: 8,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalTitle: {
fontWeight: "600",
color: colors.textPrimary,
},
modalList: {
maxHeight: 300,
},
historyItem: {
flexDirection: "row",
alignItems: "center",
padding: 14,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
historyItemActive: {
backgroundColor: colors.primaryLight,
},
historyIcon: {
width: 36,
height: 36,
backgroundColor: colors.background,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
},
historyIconElder: {
width: 44,
height: 44,
},
historyInfo: {
flex: 1,
marginLeft: 10,
},
historyTitle: {
color: colors.textPrimary,
},
historyTime: {
color: colors.textHint,
marginTop: 2,
},
deleteBtn: {
padding: 8,
},
newChatBtn: {
margin: 16,
padding: 14,
backgroundColor: colors.primary,
borderRadius: 12,
alignItems: "center",
},
newChatBtnElder: {
padding: 18,
},
newChatText: {
color: "#fff",
fontWeight: "600",
},
});

456
app/src/screens/chat/ChatListScreen.tsx

@ -0,0 +1,456 @@
import React, { useState, useCallback } from "react";
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
} from "react-native";
import {
Card,
FAB,
Portal,
Modal,
Button,
IconButton,
} from "react-native-paper";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import { useChatStore } from "../../stores/chatStore";
import {
useSettingsStore,
getFontSize,
getSpacing,
} from "../../stores/settingsStore";
import { useAlert } from "../../components";
import { colors } from "../../theme";
export default function ChatListScreen() {
const navigation = useNavigation<any>();
const { conversations, addConversation, deleteConversation } = useChatStore();
const { elderMode } = useSettingsStore();
const { showAlert, showToast } = useAlert();
const [showHistory, setShowHistory] = useState(false);
const [editMode, setEditMode] = useState(false);
const fontSize = (base: number) => getFontSize(elderMode, base);
const spacing = (base: number) => getSpacing(elderMode, base);
// 页面聚焦时刷新对话列表并检查是否需要自动跳转
const { fetchConversations } = useChatStore();
const [hasAutoNavigated, setHasAutoNavigated] = useState(false);
useFocusEffect(
useCallback(() => {
const loadAndNavigate = async () => {
console.log("[ChatList] 页面聚焦,刷新对话列表");
// 从服务器刷新对话列表
await fetchConversations();
// 获取最新的 conversations
const { conversations: latestConversations } = useChatStore.getState();
console.log(
"[ChatList] 获取到对话:",
latestConversations?.length || 0,
"条"
);
// 如果有对话且还没自动跳转过,则跳转到最近的对话
if (
!hasAutoNavigated &&
latestConversations &&
latestConversations.length > 0
) {
setHasAutoNavigated(true); // 防止重复跳转
navigation.replace("ChatDetail", { id: latestConversations[0].id });
}
};
loadAndNavigate();
}, [hasAutoNavigated])
);
const handleCreate = async () => {
// 调用 API 在后端创建对话
const { createConversation } = useChatStore.getState();
const newConv = await createConversation("新对话");
if (newConv) {
navigation.navigate("ChatDetail", { id: newConv.id });
} else {
// 如果 API 创建失败,显示提示
showToast("创建对话失败,请稍后重试");
}
};
const handleDelete = (id: string) => {
// 先关闭 Modal,然后延迟显示确认弹窗
setShowHistory(false);
setEditMode(false);
// 直接使用 setTimeout 延迟显示确认弹窗
setTimeout(() => {
showAlert("确认删除", "确定要删除这个对话吗?删除后不可恢复。", [
{ text: "取消", style: "cancel" },
{
text: "删除",
style: "destructive",
onPress: async () => {
const success = await deleteConversation(id);
if (success) {
showToast("删除成功");
} else {
showToast("删除失败,请重试");
}
},
},
]);
}, 350);
};
// Modal 关闭回调(用户手动关闭时)
const handleModalDismiss = () => {
setShowHistory(false);
setEditMode(false);
};
const formatTime = (time: string) => {
const date = new Date(time);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return `今天 ${date.getHours().toString().padStart(2, "0")}:${date
.getMinutes()
.toString()
.padStart(2, "0")}`;
}
return `${(date.getMonth() + 1).toString().padStart(2, "0")}-${date
.getDate()
.toString()
.padStart(2, "0")} ${date.getHours().toString().padStart(2, "0")}:${date
.getMinutes()
.toString()
.padStart(2, "0")}`;
};
return (
<View style={styles.container}>
<View style={[styles.header, elderMode && styles.headerElder]}>
<Text style={[styles.title, { fontSize: fontSize(20) }]}>AI问答</Text>
<TouchableOpacity
style={[styles.historyBtn, elderMode && styles.historyBtnElder]}
onPress={() => setShowHistory(true)}
>
<Text style={[styles.historyBtnText, { fontSize: fontSize(13) }]}>
</Text>
</TouchableOpacity>
</View>
{/* 空状态 - 引导新建对话 */}
{(!conversations || conversations.length === 0) && (
<View style={styles.empty}>
<Text style={{ fontSize: fontSize(48) }}>💬</Text>
<Text style={[styles.emptyText, { fontSize: fontSize(18) }]}>
</Text>
<Text style={[styles.emptyHint, { fontSize: fontSize(14) }]}>
</Text>
<Button
mode="contained"
onPress={handleCreate}
style={[styles.emptyBtn, elderMode && styles.emptyBtnElder]}
contentStyle={elderMode ? styles.emptyBtnContentElder : undefined}
labelStyle={{ fontSize: fontSize(15) }}
buttonColor={colors.primary}
>
</Button>
</View>
)}
{/* 历史记录弹窗 */}
<Portal>
<Modal
visible={showHistory}
onDismiss={handleModalDismiss}
contentContainerStyle={styles.modal}
>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { fontSize: fontSize(18) }]}>
</Text>
<View style={styles.modalActions}>
<TouchableOpacity onPress={() => setEditMode(!editMode)}>
<Text style={[styles.editBtn, { fontSize: fontSize(14) }]}>
{editMode ? "完成" : "编辑"}
</Text>
</TouchableOpacity>
<IconButton
icon="close"
size={elderMode ? 24 : 20}
onPress={() => {
setShowHistory(false);
setEditMode(false);
}}
/>
</View>
</View>
<ScrollView style={styles.modalList}>
{!conversations || conversations.length === 0 ? (
<Text style={[styles.modalEmpty, { fontSize: fontSize(14) }]}>
</Text>
) : (
conversations.map((conv) => (
<TouchableOpacity
key={conv.id}
style={[
styles.historyItem,
elderMode && styles.historyItemElder,
]}
onPress={() => {
setShowHistory(false);
setEditMode(false);
navigation.navigate("ChatDetail", { id: conv.id });
}}
>
<View
style={[
styles.historyIcon,
elderMode && styles.historyIconElder,
]}
>
<Text style={{ fontSize: fontSize(18) }}>💬</Text>
</View>
<View style={styles.historyInfo}>
<Text
style={[styles.historyTitle, { fontSize: fontSize(15) }]}
numberOfLines={1}
>
{conv.title}
</Text>
<Text
style={[styles.historyTime, { fontSize: fontSize(12) }]}
>
{formatTime(conv.updatedAt)}
</Text>
</View>
{editMode && (
<TouchableOpacity
style={[
styles.deleteBtn,
elderMode && styles.deleteBtnElder,
]}
onPress={() => handleDelete(conv.id)}
>
<Text
style={[
styles.deleteBtnText,
{ fontSize: fontSize(12) },
]}
>
</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
))
)}
</ScrollView>
<Button
mode="contained"
onPress={() => {
setShowHistory(false);
setEditMode(false);
handleCreate();
}}
style={[styles.newConvBtn, elderMode && styles.newConvBtnElder]}
contentStyle={elderMode ? styles.newConvBtnContentElder : undefined}
labelStyle={{ fontSize: fontSize(15) }}
buttonColor={colors.primary}
>
</Button>
</Modal>
</Portal>
{/* 只有有对话历史时才显示 FAB,空状态时页面中间已有"开始对话"按钮 */}
{conversations && conversations.length > 0 && (
<FAB
icon="plus"
style={[styles.fab, elderMode && styles.fabElder]}
onPress={handleCreate}
color="#fff"
label="新建对话"
size={elderMode ? "medium" : "small"}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 20,
paddingTop: 60,
backgroundColor: colors.surface,
},
headerElder: {
paddingTop: 70,
padding: 24,
},
title: {
fontWeight: "600",
color: colors.textPrimary,
},
historyBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: colors.primaryLight,
borderRadius: 16,
},
historyBtnElder: {
paddingHorizontal: 16,
paddingVertical: 8,
},
historyBtnText: {
color: colors.primary,
fontWeight: "500",
},
empty: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingBottom: 100,
},
emptyText: {
color: colors.textSecondary,
marginTop: 16,
},
emptyHint: {
color: colors.textHint,
marginTop: 8,
textAlign: "center",
paddingHorizontal: 40,
},
emptyBtn: {
marginTop: 24,
borderRadius: 20,
},
emptyBtnElder: {
marginTop: 32,
},
emptyBtnContentElder: {
height: 52,
},
fab: {
position: "absolute",
right: 20,
bottom: 20,
backgroundColor: colors.primary,
},
fabElder: {
right: 24,
bottom: 24,
},
modal: {
backgroundColor: colors.surface,
margin: 20,
borderRadius: 16,
maxHeight: "70%",
},
modalHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalTitle: {
fontWeight: "600",
color: colors.textPrimary,
},
modalActions: {
flexDirection: "row",
alignItems: "center",
},
editBtn: {
color: colors.primary,
marginRight: 8,
},
modalList: {
maxHeight: 400,
},
modalEmpty: {
textAlign: "center",
color: colors.textHint,
padding: 40,
},
historyItem: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
historyItemElder: {
padding: 20,
},
historyIcon: {
width: 40,
height: 40,
backgroundColor: colors.primaryLight,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
historyIconElder: {
width: 48,
height: 48,
},
historyInfo: {
flex: 1,
marginLeft: 12,
},
historyTitle: {
color: colors.textPrimary,
},
historyTime: {
color: colors.textHint,
marginTop: 2,
},
deleteBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#FEE2E2",
borderRadius: 12,
},
deleteBtnElder: {
paddingHorizontal: 16,
paddingVertical: 8,
},
deleteBtnText: {
color: colors.danger,
},
newConvBtn: {
margin: 16,
borderRadius: 12,
},
newConvBtnElder: {
margin: 20,
},
newConvBtnContentElder: {
height: 52,
},
});

254
app/src/screens/constitution/ConstitutionHomeScreen.tsx

@ -0,0 +1,254 @@
import React from "react";
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
} from "react-native";
import { Card, Button } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { useConstitutionStore } from "../../stores/constitutionStore";
import {
useSettingsStore,
getFontSize,
getSpacing,
} from "../../stores/settingsStore";
import { constitutionNames } from "../../mock/constitution";
import { colors } from "../../theme";
export default function ConstitutionHomeScreen() {
const navigation = useNavigation<any>();
const { result } = useConstitutionStore();
const { elderMode } = useSettingsStore();
const fontSize = (base: number) => getFontSize(elderMode, base);
const spacing = (base: number) => getSpacing(elderMode, base);
return (
<ScrollView style={styles.container}>
<View style={[styles.header, elderMode && styles.headerElder]}>
<Text style={[styles.title, { fontSize: fontSize(20) }]}></Text>
</View>
{result && result.primaryType && (
<TouchableOpacity
onPress={() => navigation.navigate("ConstitutionResult")}
>
<Card
style={[styles.resultCard, elderMode && styles.resultCardElder]}
>
<Card.Content style={styles.resultContent}>
<Text style={{ fontSize: fontSize(16) }}></Text>
<Text style={[styles.resultText, { fontSize: fontSize(14) }]}>
<Text style={styles.resultType}>
{constitutionNames[result.primaryType] || result.primaryType}
</Text>
</Text>
<Text style={[styles.arrow, { fontSize: fontSize(18) }]}></Text>
</Card.Content>
</Card>
</TouchableOpacity>
)}
<Card style={[styles.introCard, elderMode && styles.introCardElder]}>
<Card.Content style={styles.introContent}>
<Text style={{ fontSize: fontSize(48) }}>📊</Text>
<Text style={[styles.introTitle, { fontSize: fontSize(22) }]}>
</Text>
<Text style={[styles.introDesc, { fontSize: fontSize(14) }]}>
</Text>
</Card.Content>
</Card>
<Card style={[styles.stepsCard, elderMode && styles.stepsCardElder]}>
<Card.Title title="测试说明" titleStyle={{ fontSize: fontSize(16) }} />
<Card.Content>
{[
{
num: "1",
title: "回答20个问题",
desc: "根据您的真实情况选择答案",
},
{ num: "2", title: "获取分析报告", desc: "系统为您分析体质类型" },
{ num: "3", title: "个性化建议", desc: "提供针对性的健康建议" },
].map((step, i) => (
<View
key={i}
style={[styles.stepItem, elderMode && styles.stepItemElder]}
>
<View style={[styles.stepNum, elderMode && styles.stepNumElder]}>
<Text style={[styles.stepNumText, { fontSize: fontSize(14) }]}>
{step.num}
</Text>
</View>
<View style={styles.stepText}>
<Text style={[styles.stepTitle, { fontSize: fontSize(15) }]}>
{step.title}
</Text>
<Text style={[styles.stepDesc, { fontSize: fontSize(13) }]}>
{step.desc}
</Text>
</View>
</View>
))}
</Card.Content>
</Card>
<Button
mode="contained"
onPress={() => navigation.navigate("ConstitutionTest")}
style={[styles.startBtn, elderMode && styles.startBtnElder]}
contentStyle={[
styles.startBtnContent,
elderMode && styles.startBtnContentElder,
]}
labelStyle={{ fontSize: fontSize(16) }}
buttonColor={colors.primary}
>
{result ? "重新测评" : "开始测试"}
</Button>
<Text style={[styles.note, { fontSize: fontSize(12) }]}>
3-6
</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
padding: 20,
paddingTop: 60,
backgroundColor: colors.surface,
},
headerElder: {
paddingTop: 70,
padding: 24,
},
title: {
fontWeight: "600",
color: colors.textPrimary,
},
resultCard: {
margin: 16,
backgroundColor: colors.primaryLight,
borderRadius: 12,
},
resultCardElder: {
margin: 20,
},
resultContent: {
flexDirection: "row",
alignItems: "center",
},
resultText: {
flex: 1,
marginLeft: 8,
color: colors.textSecondary,
},
resultType: {
fontWeight: "600",
color: colors.primary,
},
arrow: {
color: colors.primary,
},
introCard: {
margin: 16,
marginTop: 0,
borderRadius: 16,
overflow: "hidden",
},
introCardElder: {
margin: 20,
marginTop: 0,
},
introContent: {
alignItems: "center",
paddingVertical: 24,
backgroundColor: colors.primary,
},
introTitle: {
fontWeight: "600",
color: "#fff",
marginTop: 12,
},
introDesc: {
color: "rgba(255,255,255,0.9)",
textAlign: "center",
marginTop: 8,
paddingHorizontal: 20,
lineHeight: 22,
},
stepsCard: {
margin: 16,
marginTop: 0,
borderRadius: 12,
},
stepsCardElder: {
margin: 20,
marginTop: 0,
},
stepItem: {
flexDirection: "row",
marginBottom: 16,
},
stepItemElder: {
marginBottom: 20,
},
stepNum: {
width: 28,
height: 28,
backgroundColor: colors.primary,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
},
stepNumElder: {
width: 34,
height: 34,
borderRadius: 17,
},
stepNumText: {
color: "#fff",
fontWeight: "600",
},
stepText: {
marginLeft: 12,
flex: 1,
},
stepTitle: {
fontWeight: "500",
color: colors.textPrimary,
},
stepDesc: {
color: colors.textHint,
marginTop: 2,
},
startBtn: {
marginHorizontal: 16,
borderRadius: 25,
},
startBtnElder: {
marginHorizontal: 20,
},
startBtnContent: {
height: 50,
},
startBtnContentElder: {
height: 58,
},
note: {
textAlign: "center",
color: colors.textHint,
marginVertical: 16,
},
});

519
app/src/screens/constitution/ConstitutionResultScreen.tsx

@ -0,0 +1,519 @@
import React from "react";
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
} from "react-native";
import { Card, Button, Chip } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { useConstitutionStore } from "../../stores/constitutionStore";
import {
useSettingsStore,
getFontSize,
getSpacing,
} from "../../stores/settingsStore";
import {
constitutionNames,
constitutionDescriptions,
} from "../../mock/constitution";
import { getProductsByConstitution } from "../../mock/products";
import { colors } from "../../theme";
export default function ConstitutionResultScreen() {
const navigation = useNavigation<any>();
const { result } = useConstitutionStore();
const { elderMode } = useSettingsStore();
const fontSize = (base: number) => getFontSize(elderMode, base);
const spacing = (base: number) => getSpacing(elderMode, base);
if (!result) {
return (
<View style={styles.container}>
<Text style={[styles.emptyText, { fontSize: fontSize(16) }]}>
</Text>
</View>
);
}
// 防御性获取体质描述,如果没有则使用默认值
const info = constitutionDescriptions[result.primaryType] || {
description: "您的体质类型分析结果",
features: ["需要进一步分析"],
suggestions: ["建议咨询专业医师获取更详细的建议"],
};
const products = getProductsByConstitution(result.primaryType);
const suggestionIcons = ["🌅", "🍲", "🏃", "💚"];
return (
<ScrollView style={styles.container}>
<View style={[styles.header, elderMode && styles.headerElder]}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={[styles.back, { fontSize: fontSize(16) }]}> </Text>
</TouchableOpacity>
</View>
{/* 主体质卡片 */}
<Card style={[styles.primaryCard, elderMode && styles.primaryCardElder]}>
<Card.Content
style={[
styles.primaryContent,
elderMode && styles.primaryContentElder,
]}
>
<Text style={[styles.primaryTitle, { fontSize: fontSize(18) }]}>
</Text>
<Text style={[styles.primarySubtitle, { fontSize: fontSize(14) }]}>
</Text>
<View style={styles.primaryType}>
<Text style={[styles.typeName, { fontSize: fontSize(32) }]}>
{constitutionNames[result.primaryType]}
</Text>
<View style={[styles.scoreTag, elderMode && styles.scoreTagElder]}>
<Text style={[styles.scoreText, { fontSize: fontSize(14) }]}>
{Math.round(result.scores[result.primaryType] || 0)}
</Text>
</View>
</View>
<View style={[styles.statusTag, elderMode && styles.statusTagElder]}>
<Text style={[styles.statusText, { fontSize: fontSize(14) }]}>
</Text>
</View>
</Card.Content>
</Card>
{/* 体质得分 */}
<Card style={[styles.scoresCard, elderMode && styles.scoresCardElder]}>
<Card.Title
title="📊 体质得分"
titleStyle={{ fontSize: fontSize(16) }}
/>
<Card.Content>
<View style={styles.scoresList}>
{Object.entries(result.scores).map(([type, score]) => (
<View
key={type}
style={[styles.scoreItem, elderMode && styles.scoreItemElder]}
>
<Text style={[styles.scoreName, { fontSize: fontSize(12) }]}>
{constitutionNames[type as keyof typeof constitutionNames]}
</Text>
<View
style={[
styles.scoreBarBg,
elderMode && styles.scoreBarBgElder,
]}
>
<View
style={[
styles.scoreBar,
{ width: `${Math.min(Math.round(score), 100)}%` },
]}
/>
</View>
<Text style={[styles.scoreValue, { fontSize: fontSize(12) }]}>
{Math.round(score)}
</Text>
</View>
))}
</View>
</Card.Content>
</Card>
{/* 体质特征 */}
<Card
style={[styles.featuresCard, elderMode && styles.featuresCardElder]}
>
<Card.Title
title="📋 体质特征"
titleStyle={{ fontSize: fontSize(16) }}
/>
<Card.Content>
<Text style={[styles.featuresDesc, { fontSize: fontSize(15) }]}>
{info.description}
</Text>
<View style={styles.featuresTags}>
{info.features.map((feature, i) => (
<Chip
key={i}
style={[
styles.featureChip,
elderMode && styles.featureChipElder,
]}
textStyle={{ fontSize: fontSize(13) }}
>
{feature}
</Chip>
))}
</View>
</Card.Content>
</Card>
{/* 调理建议 */}
<Card
style={[
styles.suggestionsCard,
elderMode && styles.suggestionsCardElder,
]}
>
<Card.Title
title="💡 调理建议"
titleStyle={{ fontSize: fontSize(16) }}
/>
<Card.Content>
{info.suggestions.map((suggestion, i) => (
<View
key={i}
style={[
styles.suggestionItem,
elderMode && styles.suggestionItemElder,
]}
>
<Text style={{ fontSize: fontSize(20) }}>
{suggestionIcons[i % 4]}
</Text>
<Text style={[styles.suggestionText, { fontSize: fontSize(14) }]}>
{suggestion}
</Text>
</View>
))}
</Card.Content>
</Card>
{/* 推荐调养产品 */}
{products.length > 0 && (
<Card
style={[styles.productsCard, elderMode && styles.productsCardElder]}
>
<Card.Title
title="🛒 推荐调养产品"
titleStyle={{ fontSize: fontSize(16) }}
/>
<Card.Content>
<Text style={[styles.productsHint, { fontSize: fontSize(12) }]}>
</Text>
<View style={styles.productsList}>
{products.map((product) => (
<TouchableOpacity
key={product.id}
style={[
styles.productItem,
elderMode && styles.productItemElder,
]}
>
<View
style={[
styles.productImage,
elderMode && styles.productImageElder,
]}
>
<Text style={{ fontSize: fontSize(24) }}>💊</Text>
</View>
<View style={styles.productInfo}>
<Text
style={[styles.productName, { fontSize: fontSize(15) }]}
>
{product.name}
</Text>
<Text
style={[
styles.productEfficacy,
{ fontSize: fontSize(12) },
]}
numberOfLines={1}
>
{product.efficacy}
</Text>
<Text
style={[styles.productPrice, { fontSize: fontSize(15) }]}
>
¥{product.price}
</Text>
</View>
</TouchableOpacity>
))}
</View>
</Card.Content>
</Card>
)}
{/* 操作按钮 */}
<View style={[styles.actions, elderMode && styles.actionsElder]}>
<Button
mode="contained"
onPress={() => navigation.navigate("Chat")}
style={styles.actionBtn}
contentStyle={elderMode ? styles.actionBtnContentElder : undefined}
labelStyle={{ fontSize: fontSize(15) }}
buttonColor={colors.primary}
>
AI助手
</Button>
<Button
mode="outlined"
onPress={() => navigation.navigate("ConstitutionTest")}
style={styles.actionBtn}
contentStyle={elderMode ? styles.actionBtnContentElder : undefined}
labelStyle={{ fontSize: fontSize(15) }}
>
</Button>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
emptyText: {
textAlign: "center",
marginTop: 100,
color: colors.textSecondary,
},
header: {
padding: 16,
paddingTop: 50,
},
headerElder: {
padding: 20,
paddingTop: 56,
},
back: {
color: colors.primary,
},
primaryCard: {
margin: 16,
marginTop: 0,
borderRadius: 16,
overflow: "hidden",
},
primaryCardElder: {
margin: 20,
marginTop: 0,
},
primaryContent: {
alignItems: "center",
paddingVertical: 24,
backgroundColor: colors.primary,
},
primaryContentElder: {
paddingVertical: 32,
},
primaryTitle: {
color: "#fff",
},
primarySubtitle: {
color: "rgba(255,255,255,0.8)",
marginTop: 4,
},
primaryType: {
flexDirection: "row",
alignItems: "center",
marginTop: 16,
gap: 12,
},
typeName: {
fontWeight: "bold",
color: "#fff",
},
scoreTag: {
backgroundColor: "rgba(255,255,255,0.2)",
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
},
scoreTagElder: {
paddingHorizontal: 16,
paddingVertical: 6,
},
scoreText: {
color: "#fff",
fontWeight: "600",
},
statusTag: {
backgroundColor: "rgba(255,255,255,0.2)",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
marginTop: 12,
},
statusTagElder: {
paddingHorizontal: 20,
paddingVertical: 10,
},
statusText: {
color: "#fff",
},
scoresCard: {
margin: 16,
marginTop: 0,
borderRadius: 12,
},
scoresCardElder: {
margin: 20,
marginTop: 0,
},
scoresList: {
gap: 12,
},
scoreItem: {
flexDirection: "row",
alignItems: "center",
},
scoreItemElder: {
marginBottom: 4,
},
scoreName: {
width: 60,
color: colors.textSecondary,
},
scoreBarBg: {
flex: 1,
height: 8,
backgroundColor: colors.background,
borderRadius: 4,
marginHorizontal: 8,
},
scoreBarBgElder: {
height: 10,
},
scoreBar: {
height: "100%",
backgroundColor: colors.primary,
borderRadius: 4,
},
scoreValue: {
width: 30,
textAlign: "right",
color: colors.textSecondary,
},
featuresCard: {
margin: 16,
marginTop: 0,
borderRadius: 12,
},
featuresCardElder: {
margin: 20,
marginTop: 0,
},
featuresDesc: {
lineHeight: 26,
color: colors.textSecondary,
marginBottom: 12,
},
featuresTags: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
featureChip: {
backgroundColor: colors.background,
},
featureChipElder: {
height: 36,
},
suggestionsCard: {
margin: 16,
marginTop: 0,
borderRadius: 12,
},
suggestionsCardElder: {
margin: 20,
marginTop: 0,
},
suggestionItem: {
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: colors.background,
padding: 12,
borderRadius: 12,
marginBottom: 8,
gap: 12,
},
suggestionItemElder: {
padding: 16,
marginBottom: 12,
},
suggestionText: {
flex: 1,
lineHeight: 24,
color: colors.textSecondary,
},
productsCard: {
margin: 16,
marginTop: 0,
borderRadius: 12,
},
productsCardElder: {
margin: 20,
marginTop: 0,
},
productsHint: {
color: colors.textHint,
marginBottom: 12,
},
productsList: {
gap: 12,
},
productItem: {
flexDirection: "row",
backgroundColor: colors.background,
padding: 12,
borderRadius: 12,
},
productItemElder: {
padding: 16,
},
productImage: {
width: 56,
height: 56,
backgroundColor: colors.primaryLight,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
productImageElder: {
width: 64,
height: 64,
},
productInfo: {
flex: 1,
marginLeft: 12,
},
productName: {
fontWeight: "500",
color: colors.textPrimary,
},
productEfficacy: {
color: colors.textSecondary,
marginTop: 2,
},
productPrice: {
color: colors.danger,
fontWeight: "600",
marginTop: 4,
},
actions: {
padding: 16,
gap: 12,
},
actionsElder: {
padding: 20,
},
actionBtn: {
borderRadius: 12,
},
actionBtnContentElder: {
height: 52,
},
});

314
app/src/screens/constitution/ConstitutionTestScreen.tsx

@ -0,0 +1,314 @@
import React, { useState, useEffect } from 'react'
import { View, StyleSheet, ScrollView, Text, TouchableOpacity, ActivityIndicator } from 'react-native'
import { Card, Button, ProgressBar } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { useConstitutionStore } from '../../stores/constitutionStore'
import { useSettingsStore, getFontSize, getSpacing } from '../../stores/settingsStore'
import { useAlert } from '../../components'
import { colors } from '../../theme'
export default function ConstitutionTestScreen() {
const navigation = useNavigation<any>()
const { questions, fetchQuestions, submitAnswers, isLoading, error } = useConstitutionStore()
const { elderMode } = useSettingsStore()
const { showToast, showAlert } = useAlert()
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<number, number>>({})
const [submitting, setSubmitting] = useState(false)
const [loading, setLoading] = useState(true)
const fontSize = (base: number) => getFontSize(elderMode, base)
// 加载问题
useEffect(() => {
const loadQuestions = async () => {
setLoading(true)
await fetchQuestions()
setLoading(false)
}
loadQuestions()
}, [])
if (loading || questions.length === 0) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { fontSize: fontSize(14) }]}>...</Text>
</View>
)
}
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 handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const handleNext = () => {
if (!answers[currentQuestion.id]) {
showToast('请选择一个选项')
return
}
if (currentIndex < questions.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
const handleSubmit = async () => {
if (Object.keys(answers).length < questions.length) {
showToast('请完成所有题目')
return
}
setSubmitting(true)
try {
const result = await submitAnswers(answers)
if (result) {
navigation.navigate('ConstitutionResult')
} else {
showAlert('提交失败', error || '请稍后重试')
}
} finally {
setSubmitting(false)
}
}
return (
<View style={styles.container}>
<View style={[styles.header, elderMode && styles.headerElder]}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={[styles.back, { fontSize: fontSize(16) }]}> </Text>
</TouchableOpacity>
<Text style={[styles.title, { fontSize: fontSize(16) }]}></Text>
<View style={{ width: 50 }} />
</View>
<Card style={[styles.progressCard, elderMode && styles.progressCardElder]}>
<Card.Content>
<Text style={[styles.progressText, { fontSize: fontSize(14) }]}>
{currentIndex + 1} / {questions.length}
</Text>
<ProgressBar
progress={progress}
color={colors.primary}
style={[styles.progressBar, elderMode && styles.progressBarElder]}
/>
</Card.Content>
</Card>
<ScrollView style={styles.questionArea}>
<Card style={[styles.questionCard, elderMode && styles.questionCardElder]}>
<Card.Content>
<View style={[styles.questionTag, elderMode && styles.questionTagElder]}>
<Text style={[styles.questionTagText, { fontSize: fontSize(12) }]}>{currentIndex + 1}</Text>
</View>
<Text style={[styles.questionText, { fontSize: fontSize(18) }]}>{currentQuestion.question}</Text>
<View style={styles.options}>
{currentQuestion.options.map(option => (
<TouchableOpacity
key={option.value}
style={[
styles.optionItem,
elderMode && styles.optionItemElder,
answers[currentQuestion.id] === option.value && styles.optionActive
]}
onPress={() => selectOption(option.value)}
>
<Text style={[
styles.optionText,
{ fontSize: fontSize(15) },
answers[currentQuestion.id] === option.value && styles.optionTextActive
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</Card.Content>
</Card>
<Text style={[styles.hint, { fontSize: fontSize(12) }]}>
</Text>
</ScrollView>
<View style={[styles.navButtons, elderMode && styles.navButtonsElder]}>
<Button
mode="outlined"
onPress={handlePrev}
disabled={currentIndex === 0}
style={styles.navBtn}
contentStyle={elderMode ? styles.navBtnContentElder : undefined}
labelStyle={{ fontSize: fontSize(14) }}
>
</Button>
{!isLastQuestion ? (
<Button
mode="contained"
onPress={handleNext}
disabled={!answers[currentQuestion.id]}
style={styles.navBtn}
contentStyle={elderMode ? styles.navBtnContentElder : undefined}
labelStyle={{ fontSize: fontSize(14) }}
buttonColor={colors.primary}
>
</Button>
) : (
<Button
mode="contained"
onPress={handleSubmit}
loading={submitting}
style={styles.navBtn}
contentStyle={elderMode ? styles.navBtnContentElder : undefined}
labelStyle={{ fontSize: fontSize(14) }}
buttonColor={colors.primary}
>
</Button>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background
},
loadingText: {
marginTop: 12,
color: colors.textSecondary
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
paddingTop: 50,
backgroundColor: colors.surface
},
headerElder: {
paddingTop: 56,
padding: 20
},
back: {
color: colors.primary
},
title: {
fontWeight: '600',
color: colors.textPrimary
},
progressCard: {
margin: 16,
borderRadius: 12
},
progressCardElder: {
margin: 20
},
progressText: {
textAlign: 'center',
color: colors.textSecondary,
marginBottom: 12
},
progressBar: {
height: 8,
borderRadius: 4
},
progressBarElder: {
height: 10
},
questionArea: {
flex: 1,
padding: 16,
paddingTop: 0
},
questionCard: {
borderRadius: 12
},
questionCardElder: {
borderRadius: 16
},
questionTag: {
backgroundColor: colors.primaryLight,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
alignSelf: 'flex-start',
marginBottom: 16
},
questionTagElder: {
paddingHorizontal: 16,
paddingVertical: 6
},
questionTagText: {
color: colors.primary,
fontWeight: '500'
},
questionText: {
lineHeight: 28,
color: colors.textPrimary,
marginBottom: 20
},
options: {
gap: 12
},
optionItem: {
padding: 16,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12
},
optionItemElder: {
padding: 20
},
optionActive: {
borderColor: colors.primary,
backgroundColor: colors.primary
},
optionText: {
color: colors.textPrimary,
textAlign: 'center'
},
optionTextActive: {
color: '#fff'
},
hint: {
textAlign: 'center',
color: colors.textHint,
marginTop: 16,
marginBottom: 20
},
navButtons: {
flexDirection: 'row',
padding: 16,
gap: 16,
backgroundColor: colors.surface
},
navButtonsElder: {
padding: 20
},
navBtn: {
flex: 1
},
navBtnContentElder: {
height: 52
}
})

434
app/src/screens/home/HomeScreen.tsx

@ -0,0 +1,434 @@
import React from "react";
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
} from "react-native";
import { Card } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { useAuthStore } from "../../stores/authStore";
import { useConstitutionStore } from "../../stores/constitutionStore";
import {
useSettingsStore,
getFontSize,
getSpacing,
} from "../../stores/settingsStore";
import {
constitutionNames,
constitutionDescriptions,
} from "../../mock/constitution";
import { mockHealthNews } from "../../mock/news";
import { colors } from "../../theme";
export default function HomeScreen() {
const navigation = useNavigation<any>();
const { user } = useAuthStore();
const { result } = useConstitutionStore();
const { elderMode } = useSettingsStore();
const fontSize = (base: number) => getFontSize(elderMode, base);
const spacing = (base: number) => getSpacing(elderMode, base);
const quickActions = [
{
icon: "💬",
label: "AI问诊",
color: "#3B82F6",
onPress: () => navigation.navigate("Chat"),
},
{
icon: "📊",
label: "体质测试",
color: colors.primary,
onPress: () => navigation.navigate("Constitution"),
},
{
icon: "📋",
label: "健康档案",
color: "#8B5CF6",
onPress: () => navigation.navigate("Profile"),
},
{
icon: "💊",
label: "用药记录",
color: "#F59E0B",
onPress: () => navigation.navigate("Profile"),
},
];
// 获取健康提示,添加防御性检查
const healthTip =
result && result.primaryType && constitutionDescriptions[result.primaryType]
? constitutionDescriptions[result.primaryType].suggestions[0]
: "保持良好的作息习惯,每天喝足8杯水,适当运动有益身心健康。";
// 只显示前4条资讯
const displayNews = mockHealthNews.slice(0, 4);
return (
<ScrollView style={styles.container}>
<View style={[styles.header, elderMode && styles.headerElder]}>
<Text style={[styles.greeting, { fontSize: fontSize(22) }]}>
{getGreeting()}{user?.nickname || "健康达人"}
</Text>
<Text style={[styles.subGreeting, { fontSize: fontSize(14) }]}>
</Text>
</View>
{/* 体质卡片 */}
<Card
style={[
styles.constitutionCard,
elderMode && styles.constitutionCardElder,
]}
>
{result &&
result.primaryType &&
constitutionDescriptions[result.primaryType] ? (
<TouchableOpacity
onPress={() =>
navigation.navigate("Constitution", {
screen: "ConstitutionResult",
})
}
>
<Card.Content style={styles.constitutionContent}>
<View
style={[
styles.constitutionIcon,
elderMode && styles.constitutionIconElder,
]}
>
<Text style={{ fontSize: fontSize(32) }}>📊</Text>
</View>
<View style={styles.constitutionInfo}>
<Text
style={[styles.constitutionLabel, { fontSize: fontSize(12) }]}
>
</Text>
<Text
style={[styles.constitutionType, { fontSize: fontSize(20) }]}
>
{constitutionNames[result.primaryType] || result.primaryType}
</Text>
<Text
style={[styles.constitutionDesc, { fontSize: fontSize(12) }]}
numberOfLines={1}
>
{constitutionDescriptions[result.primaryType].description}
</Text>
</View>
<Text style={[styles.arrow, { fontSize: fontSize(18) }]}></Text>
</Card.Content>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => navigation.navigate("Constitution")}>
<Card.Content
style={[
styles.noConstitution,
elderMode && styles.noConstitutionElder,
]}
>
<Text style={{ fontSize: fontSize(40) }}>📋</Text>
<Text
style={[styles.noConstitutionText, { fontSize: fontSize(16) }]}
>
</Text>
<Text
style={[styles.noConstitutionHint, { fontSize: fontSize(12) }]}
>
</Text>
</Card.Content>
</TouchableOpacity>
)}
</Card>
{/* 快捷入口 */}
<View style={styles.quickActions}>
{quickActions.map((action, index) => (
<TouchableOpacity
key={index}
style={[styles.actionItem, elderMode && styles.actionItemElder]}
onPress={action.onPress}
>
<View
style={[
styles.actionIcon,
{ backgroundColor: action.color + "20" },
elderMode && styles.actionIconElder,
]}
>
<Text style={{ fontSize: fontSize(24) }}>{action.icon}</Text>
</View>
<Text style={[styles.actionLabel, { fontSize: fontSize(12) }]}>
{action.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* 健康提示 */}
<Card style={[styles.tipCard, elderMode && styles.tipCardElder]}>
<Card.Content style={styles.tipContent}>
<Text style={{ fontSize: fontSize(20) }}></Text>
<View style={styles.tipText}>
<Text style={[styles.tipTitle, { fontSize: fontSize(14) }]}>
</Text>
<Text style={[styles.tipDesc, { fontSize: fontSize(13) }]}>
{healthTip}
</Text>
</View>
</Card.Content>
</Card>
{/* 健康资讯 */}
<View style={styles.newsSection}>
<View style={styles.newsHeader}>
<Text style={[styles.newsTitle, { fontSize: fontSize(16) }]}>
</Text>
<TouchableOpacity onPress={() => navigation.navigate("HealthNews")}>
<Text style={[styles.newsMore, { fontSize: fontSize(13) }]}>
</Text>
</TouchableOpacity>
</View>
{displayNews.map((news) => (
<Card
key={news.id}
style={[styles.newsCard, elderMode && styles.newsCardElder]}
>
<TouchableOpacity onPress={() => navigation.navigate("HealthNews")}>
<Card.Content style={styles.newsContent}>
<View
style={[styles.newsIcon, elderMode && styles.newsIconElder]}
>
<Text style={{ fontSize: fontSize(20) }}>{news.icon}</Text>
</View>
<View style={styles.newsInfo}>
<Text
style={[styles.newsItemTitle, { fontSize: fontSize(14) }]}
numberOfLines={2}
>
{news.title}
</Text>
<Text
style={[styles.newsCategory, { fontSize: fontSize(11) }]}
>
{news.category}
</Text>
</View>
</Card.Content>
</TouchableOpacity>
</Card>
))}
</View>
<View style={{ height: 20 }} />
</ScrollView>
);
}
function getGreeting() {
const hour = new Date().getHours();
if (hour < 12) return "早上好";
if (hour < 18) return "下午好";
return "晚上好";
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
padding: 20,
paddingTop: 60,
backgroundColor: colors.primary,
},
headerElder: {
paddingTop: 70,
padding: 24,
},
greeting: {
fontWeight: "600",
color: "#fff",
},
subGreeting: {
color: "rgba(255,255,255,0.8)",
marginTop: 4,
},
constitutionCard: {
margin: 16,
marginTop: -20,
borderRadius: 16,
},
constitutionCardElder: {
margin: 20,
marginTop: -24,
},
constitutionContent: {
flexDirection: "row",
alignItems: "center",
padding: 8,
},
constitutionIcon: {
width: 56,
height: 56,
backgroundColor: colors.primaryLight,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
},
constitutionIconElder: {
width: 68,
height: 68,
},
constitutionInfo: {
flex: 1,
marginLeft: 16,
},
constitutionLabel: {
color: colors.textHint,
},
constitutionType: {
fontWeight: "600",
color: colors.textPrimary,
},
constitutionDesc: {
color: colors.textSecondary,
},
arrow: {
color: colors.textHint,
},
noConstitution: {
alignItems: "center",
paddingVertical: 24,
},
noConstitutionElder: {
paddingVertical: 32,
},
noConstitutionText: {
color: colors.textSecondary,
marginTop: 8,
},
noConstitutionHint: {
color: colors.textHint,
marginTop: 4,
},
quickActions: {
flexDirection: "row",
paddingHorizontal: 16,
gap: 12,
},
actionItem: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: 12,
padding: 16,
alignItems: "center",
},
actionItemElder: {
padding: 20,
borderRadius: 16,
},
actionIcon: {
width: 48,
height: 48,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
marginBottom: 8,
},
actionIconElder: {
width: 56,
height: 56,
},
actionLabel: {
color: colors.textPrimary,
},
tipCard: {
margin: 16,
backgroundColor: "#FFFBEB",
borderRadius: 12,
},
tipCardElder: {
margin: 20,
},
tipContent: {
flexDirection: "row",
alignItems: "flex-start",
gap: 12,
},
tipText: {
flex: 1,
},
tipTitle: {
fontWeight: "600",
color: "#92400E",
},
tipDesc: {
color: "#B45309",
marginTop: 4,
lineHeight: 22,
},
newsSection: {
paddingHorizontal: 16,
},
newsHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
},
newsTitle: {
fontWeight: "600",
color: colors.textPrimary,
},
newsMore: {
color: colors.primary,
},
newsCard: {
marginBottom: 10,
borderRadius: 12,
},
newsCardElder: {
marginBottom: 14,
},
newsContent: {
flexDirection: "row",
alignItems: "center",
},
newsIcon: {
width: 44,
height: 44,
backgroundColor: colors.background,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
newsIconElder: {
width: 52,
height: 52,
},
newsInfo: {
flex: 1,
marginLeft: 12,
},
newsItemTitle: {
color: colors.textPrimary,
lineHeight: 20,
},
newsCategory: {
color: colors.textHint,
marginTop: 4,
},
});

294
app/src/screens/news/HealthNewsScreen.tsx

@ -0,0 +1,294 @@
import React, { useState } from 'react'
import { View, StyleSheet, ScrollView, Text, TouchableOpacity } from 'react-native'
import { Card, Chip, Portal, Modal } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { useSettingsStore, getFontSize, getSpacing } from '../../stores/settingsStore'
import { mockHealthNews, HealthNews } from '../../mock/news'
import { colors } from '../../theme'
export default function HealthNewsScreen() {
const navigation = useNavigation()
const { elderMode } = useSettingsStore()
const [selectedNews, setSelectedNews] = useState<HealthNews | null>(null)
const [selectedCategory, setSelectedCategory] = useState<string>('全部')
const fontSize = (base: number) => getFontSize(elderMode, base)
const spacing = (base: number) => getSpacing(elderMode, base)
const categories = ['全部', '养生', '健康', '睡眠', '饮食', '中医', '安全']
const filteredNews = selectedCategory === '全部'
? mockHealthNews
: mockHealthNews.filter(n => n.category === selectedCategory)
const formatDate = (date: string) => {
const d = new Date(date)
return `${d.getMonth() + 1}${d.getDate()}`
}
return (
<View style={styles.container}>
{/* 头部 */}
<View style={[styles.header, elderMode && styles.headerElder]}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
<Text style={[styles.backText, { fontSize: fontSize(20) }]}></Text>
</TouchableOpacity>
<Text style={[styles.title, { fontSize: fontSize(18) }]}></Text>
<View style={{ width: 40 }} />
</View>
{/* 分类筛选 */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categoryBar}
contentContainerStyle={styles.categoryContent}
>
{categories.map(cat => (
<TouchableOpacity
key={cat}
style={[
styles.categoryChip,
selectedCategory === cat && styles.categoryChipActive,
elderMode && styles.categoryChipElder
]}
onPress={() => setSelectedCategory(cat)}
>
<Text style={[
styles.categoryText,
selectedCategory === cat && styles.categoryTextActive,
{ fontSize: fontSize(13) }
]}>
{cat}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* 资讯列表 */}
<ScrollView style={styles.newsList}>
{filteredNews.map(news => (
<TouchableOpacity key={news.id} onPress={() => setSelectedNews(news)}>
<Card style={[styles.newsCard, elderMode && styles.newsCardElder]}>
<Card.Content style={styles.newsContent}>
<View style={[styles.newsIcon, elderMode && styles.newsIconElder]}>
<Text style={{ fontSize: fontSize(24) }}>{news.icon}</Text>
</View>
<View style={styles.newsInfo}>
<Text style={[styles.newsTitle, { fontSize: fontSize(15) }]} numberOfLines={2}>
{news.title}
</Text>
<Text style={[styles.newsSummary, { fontSize: fontSize(13) }]} numberOfLines={2}>
{news.summary}
</Text>
<View style={styles.newsMeta}>
<Text style={[styles.newsCategory, { fontSize: fontSize(11) }]}>{news.category}</Text>
<Text style={[styles.newsDate, { fontSize: fontSize(11) }]}>{formatDate(news.publishTime)}</Text>
<Text style={[styles.newsRead, { fontSize: fontSize(11) }]}>{news.readCount}</Text>
</View>
</View>
</Card.Content>
</Card>
</TouchableOpacity>
))}
<View style={{ height: 20 }} />
</ScrollView>
{/* 资讯详情弹窗 */}
<Portal>
<Modal
visible={!!selectedNews}
onDismiss={() => setSelectedNews(null)}
contentContainerStyle={styles.modal}
>
{selectedNews && (
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { fontSize: fontSize(18) }]}>{selectedNews.title}</Text>
<TouchableOpacity onPress={() => setSelectedNews(null)}>
<Text style={{ fontSize: fontSize(20) }}></Text>
</TouchableOpacity>
</View>
<View style={styles.modalMeta}>
<Chip style={styles.modalChip}>{selectedNews.category}</Chip>
<Text style={[styles.modalDate, { fontSize: fontSize(12) }]}>{selectedNews.publishTime}</Text>
<Text style={[styles.modalRead, { fontSize: fontSize(12) }]}>{selectedNews.readCount}</Text>
</View>
<ScrollView style={styles.modalBody}>
<Text style={[styles.modalText, { fontSize: fontSize(15) }]}>
{selectedNews.content}
</Text>
</ScrollView>
</View>
)}
</Modal>
</Portal>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
paddingTop: 50,
backgroundColor: colors.surface
},
headerElder: {
paddingTop: 56,
padding: 20
},
backBtn: {
width: 40
},
backText: {
color: colors.primary
},
title: {
fontWeight: '600',
color: colors.textPrimary
},
categoryBar: {
backgroundColor: colors.surface,
maxHeight: 60
},
categoryContent: {
paddingHorizontal: 16,
paddingBottom: 12,
gap: 8,
flexDirection: 'row'
},
categoryChip: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: colors.background,
borderRadius: 20
},
categoryChipElder: {
paddingHorizontal: 20,
paddingVertical: 10
},
categoryChipActive: {
backgroundColor: colors.primary
},
categoryText: {
color: colors.textSecondary
},
categoryTextActive: {
color: '#fff'
},
newsList: {
flex: 1,
padding: 16
},
newsCard: {
marginBottom: 12,
borderRadius: 12
},
newsCardElder: {
marginBottom: 16
},
newsContent: {
flexDirection: 'row'
},
newsIcon: {
width: 56,
height: 56,
backgroundColor: colors.background,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center'
},
newsIconElder: {
width: 64,
height: 64
},
newsInfo: {
flex: 1,
marginLeft: 12
},
newsTitle: {
fontWeight: '500',
color: colors.textPrimary,
lineHeight: 22
},
newsSummary: {
color: colors.textSecondary,
marginTop: 4,
lineHeight: 20
},
newsMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
gap: 12
},
newsCategory: {
color: colors.primary,
backgroundColor: colors.primaryLight,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8
},
newsDate: {
color: colors.textHint
},
newsRead: {
color: colors.textHint
},
modal: {
backgroundColor: colors.surface,
margin: 16,
borderRadius: 16,
maxHeight: '80%'
},
modalContent: {
flex: 1
},
modalHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border
},
modalTitle: {
flex: 1,
fontWeight: '600',
color: colors.textPrimary,
lineHeight: 26,
marginRight: 12
},
modalMeta: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12
},
modalChip: {
height: 28
},
modalDate: {
color: colors.textHint
},
modalRead: {
color: colors.textHint
},
modalBody: {
flex: 1,
padding: 16,
paddingTop: 0
},
modalText: {
color: colors.textPrimary,
lineHeight: 28
}
})

1627
app/src/screens/profile/HealthProfileScreen.tsx

File diff suppressed because it is too large

759
app/src/screens/profile/ProfileScreen.tsx

@ -0,0 +1,759 @@
import React, { useState, useEffect } from "react";
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
Switch,
} from "react-native";
import {
Card,
Avatar,
Divider,
Portal,
Modal,
IconButton,
TextInput,
Button,
} from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { useAuthStore } from "../../stores/authStore";
import { useConstitutionStore } from "../../stores/constitutionStore";
import { useHealthStore } from "../../stores/healthStore";
import {
useSettingsStore,
getFontSize,
getSpacing,
} from "../../stores/settingsStore";
import { useAlert } from "../../components";
import { constitutionNames } from "../../mock/constitution";
import { colors } from "../../theme";
export default function ProfileScreen() {
const navigation = useNavigation<any>();
const { user, logout, updateProfile } = useAuthStore();
const { result } = useConstitutionStore();
const { medicalHistory, fetchMedicalHistory } = useHealthStore();
const { elderMode, toggleElderMode } = useSettingsStore();
const { showAlert, showToast } = useAlert();
const [showMedication, setShowMedication] = useState(false);
const [showEditProfile, setShowEditProfile] = useState(false);
const [editNickname, setEditNickname] = useState(user?.nickname || "");
const [isSaving, setIsSaving] = useState(false);
// 加载病史数据
useEffect(() => {
fetchMedicalHistory();
}, []);
// 打开编辑弹窗时,初始化昵称
const handleOpenEdit = () => {
setEditNickname(user?.nickname || "");
setShowEditProfile(true);
};
// 保存用户信息
const handleSaveProfile = async () => {
if (!editNickname.trim()) {
showToast("昵称不能为空");
return;
}
setIsSaving(true);
const success = await updateProfile({ nickname: editNickname.trim() });
setIsSaving(false);
if (success) {
showToast("保存成功");
setShowEditProfile(false);
} else {
showToast("保存失败,请重试");
}
};
// 获取需要关注的病史记录作为用药/治疗记录
// 包括:治疗中(treating)、已控制(controlled,如慢性病需持续用药)
const treatingRecords = medicalHistory.filter(
(r) => r.status === "treating" || r.status === "controlled"
);
const handleLogout = () => {
showAlert("确认", "确定要退出登录吗?", [
{ text: "取消", style: "cancel" },
{
text: "退出",
style: "destructive",
onPress: async () => {
await logout();
},
},
]);
};
const fontSize = (base: number) => getFontSize(elderMode, base);
const spacing = (base: number) => getSpacing(elderMode, base);
const menuItems = [
{
icon: "📋",
title: "健康档案",
desc: "查看和管理您的健康信息",
color: colors.primary,
onPress: () => navigation.navigate("HealthProfile"),
},
{
icon: "💊",
title: "用药/治疗记录",
desc:
treatingRecords.length > 0
? `${treatingRecords.length}项需关注的病史`
: "暂无需关注的记录",
color: "#F59E0B",
onPress: () => setShowMedication(true),
},
{
icon: "📊",
title: "体质报告",
desc: result
? `当前体质:${constitutionNames[result.primaryType]}`
: "暂无测评记录",
color: "#8B5CF6",
onPress: () =>
result &&
navigation.navigate("Constitution", { screen: "ConstitutionResult" }),
},
{
icon: "💬",
title: "对话历史",
desc: "查看AI咨询记录",
color: "#3B82F6",
onPress: () => navigation.navigate("Chat"),
},
];
const otherItems = [
{
icon: "🛒",
title: "健康商城",
desc: "选购适合您的保健品",
onPress: () => {},
},
{
icon: "ℹ️",
title: "关于我们",
desc: "了解健康AI助手",
onPress: () =>
showAlert(
"关于",
"健康AI助手 v1.0.0\n\n结合中医体质辨识理论,为您提供个性化健康建议。"
),
},
];
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={[styles.headerTitle, { fontSize: fontSize(20) }]}>
</Text>
</View>
{/* 用户信息 */}
<Card style={styles.userCard}>
<Card.Content style={styles.userContent}>
<Avatar.Text
size={elderMode ? 80 : 64}
label={user?.nickname?.charAt(0) || "U"}
style={styles.avatar}
/>
<View style={styles.userInfo}>
<Text style={[styles.userName, { fontSize: fontSize(18) }]}>
{user?.nickname || "用户"}
</Text>
<Text style={[styles.userPhone, { fontSize: fontSize(14) }]}>
{user?.phone}
</Text>
{result && (
<View style={styles.typeTag}>
<Text style={[styles.typeTagText, { fontSize: fontSize(12) }]}>
{constitutionNames[result.primaryType]}
</Text>
</View>
)}
</View>
<TouchableOpacity onPress={handleOpenEdit}>
<Text style={{ fontSize: fontSize(20) }}></Text>
</TouchableOpacity>
</Card.Content>
</Card>
{/* 适老模式 */}
<Card style={styles.elderModeCard}>
<Card.Content style={styles.elderModeContent}>
<View style={styles.elderModeIcon}>
<Text style={{ fontSize: 24 }}>👴</Text>
</View>
<View style={styles.elderModeText}>
<Text style={[styles.elderModeTitle, { fontSize: fontSize(15) }]}>
</Text>
<Text style={[styles.elderModeDesc, { fontSize: fontSize(12) }]}>
便
</Text>
</View>
<Switch
value={elderMode}
onValueChange={toggleElderMode}
trackColor={{ false: "#E5E7EB", true: colors.primaryLight }}
thumbColor={elderMode ? colors.primary : "#fff"}
/>
</Card.Content>
</Card>
{/* 健康管理 */}
<Card style={styles.menuCard}>
<Card.Title
title="健康管理"
titleStyle={[styles.menuTitle, { fontSize: fontSize(14) }]}
/>
<Card.Content>
{menuItems.map((item, index) => (
<React.Fragment key={index}>
<TouchableOpacity
style={[styles.menuItem, { paddingVertical: spacing(12) }]}
onPress={item.onPress}
>
<View
style={[
styles.menuIcon,
{
backgroundColor: item.color + "20",
width: elderMode ? 48 : 40,
height: elderMode ? 48 : 40,
},
]}
>
<Text style={{ fontSize: fontSize(20) }}>{item.icon}</Text>
</View>
<View style={styles.menuText}>
<Text
style={[styles.menuItemTitle, { fontSize: fontSize(15) }]}
>
{item.title}
</Text>
<Text
style={[styles.menuItemDesc, { fontSize: fontSize(12) }]}
>
{item.desc}
</Text>
</View>
<Text
style={{ fontSize: fontSize(16), color: colors.textHint }}
>
</Text>
</TouchableOpacity>
{index < menuItems.length - 1 && (
<Divider style={styles.divider} />
)}
</React.Fragment>
))}
</Card.Content>
</Card>
{/* 其他 */}
<Card style={styles.menuCard}>
<Card.Title
title="其他"
titleStyle={[styles.menuTitle, { fontSize: fontSize(14) }]}
/>
<Card.Content>
{otherItems.map((item, index) => (
<React.Fragment key={index}>
<TouchableOpacity
style={[styles.menuItem, { paddingVertical: spacing(12) }]}
onPress={item.onPress}
>
<View
style={[
styles.menuIcon,
{
backgroundColor: colors.background,
width: elderMode ? 48 : 40,
height: elderMode ? 48 : 40,
},
]}
>
<Text style={{ fontSize: fontSize(20) }}>{item.icon}</Text>
</View>
<View style={styles.menuText}>
<Text
style={[styles.menuItemTitle, { fontSize: fontSize(15) }]}
>
{item.title}
</Text>
<Text
style={[styles.menuItemDesc, { fontSize: fontSize(12) }]}
>
{item.desc}
</Text>
</View>
<Text
style={{ fontSize: fontSize(16), color: colors.textHint }}
>
</Text>
</TouchableOpacity>
{index < otherItems.length - 1 && (
<Divider style={styles.divider} />
)}
</React.Fragment>
))}
</Card.Content>
</Card>
{/* 退出登录 */}
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
<Text style={[styles.logoutText, { fontSize: fontSize(15) }]}>
退
</Text>
</TouchableOpacity>
<Text style={[styles.version, { fontSize: fontSize(12) }]}>
1.0.0
</Text>
{/* 编辑用户信息弹窗 */}
<Portal>
<Modal
visible={showEditProfile}
onDismiss={() => setShowEditProfile(false)}
contentContainerStyle={styles.editModal}
>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { fontSize: fontSize(18) }]}>
</Text>
<IconButton
icon="close"
size={20}
onPress={() => setShowEditProfile(false)}
/>
</View>
<View style={styles.editContent}>
<Text style={[styles.editLabel, { fontSize: fontSize(14) }]}>
</Text>
<TextInput
value={editNickname}
onChangeText={setEditNickname}
mode="outlined"
placeholder="请输入昵称"
style={styles.editInput}
outlineColor={colors.border}
activeOutlineColor={colors.primary}
/>
</View>
<View style={styles.editActions}>
<Button
mode="outlined"
onPress={() => setShowEditProfile(false)}
style={styles.editCancelBtn}
textColor={colors.textSecondary}
>
</Button>
<Button
mode="contained"
onPress={handleSaveProfile}
loading={isSaving}
disabled={isSaving}
style={styles.editSaveBtn}
buttonColor={colors.primary}
>
</Button>
</View>
</Modal>
</Portal>
{/* 用药/治疗记录弹窗 */}
<Portal>
<Modal
visible={showMedication}
onDismiss={() => setShowMedication(false)}
contentContainerStyle={styles.modal}
>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { fontSize: fontSize(18) }]}>
/
</Text>
<IconButton
icon="close"
size={20}
onPress={() => setShowMedication(false)}
/>
</View>
<ScrollView style={styles.modalList}>
{treatingRecords.length > 0 ? (
treatingRecords.map((record) => (
<View
key={record.id}
style={[
styles.medicationItem,
record.status !== "treating" && styles.medicationEnded,
]}
>
<View style={styles.medicationHeader}>
<Text
style={[
styles.medicationName,
{ fontSize: fontSize(15) },
]}
>
{record.disease_name}
</Text>
<View
style={[
styles.medicationActive,
record.status === "cured" && styles.medicationCured,
record.status === "controlled" &&
styles.medicationControlled,
]}
>
<Text
style={[
styles.medicationActiveText,
{ fontSize: fontSize(10) },
]}
>
{record.status === "treating"
? "治疗中"
: record.status === "cured"
? "已治愈"
: "已控制"}
</Text>
</View>
</View>
<Text
style={[
styles.medicationDosage,
{ fontSize: fontSize(13) },
]}
>
{record.disease_type === "chronic"
? "慢性病"
: record.disease_type === "surgery"
? "手术"
: "其他"}
</Text>
<Text
style={[styles.medicationDate, { fontSize: fontSize(11) }]}
>
{record.diagnosed_date || "未知"}
</Text>
{record.notes && (
<Text
style={[
styles.medicationNotes,
{ fontSize: fontSize(12) },
]}
>
{record.notes}
</Text>
)}
</View>
))
) : (
<View style={styles.emptyMedication}>
<Text style={{ fontSize: fontSize(40) }}>📋</Text>
<Text
style={[
styles.emptyMedicationText,
{ fontSize: fontSize(14) },
]}
>
</Text>
<Text
style={[
styles.emptyMedicationHint,
{ fontSize: fontSize(12) },
]}
>
</Text>
</View>
)}
</ScrollView>
<TouchableOpacity
style={styles.addMedicationBtn}
onPress={() => {
setShowMedication(false);
navigation.navigate("HealthProfile");
}}
>
<Text
style={[styles.addMedicationText, { fontSize: fontSize(15) }]}
>
</Text>
</TouchableOpacity>
</Modal>
</Portal>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
padding: 20,
paddingTop: 60,
backgroundColor: colors.surface,
},
headerTitle: {
fontWeight: "600",
color: colors.textPrimary,
},
userCard: {
margin: 16,
borderRadius: 12,
},
userContent: {
flexDirection: "row",
alignItems: "center",
},
avatar: {
backgroundColor: colors.primary,
},
userInfo: {
flex: 1,
marginLeft: 16,
},
userName: {
fontWeight: "600",
color: colors.textPrimary,
},
userPhone: {
color: colors.textSecondary,
marginTop: 2,
},
typeTag: {
backgroundColor: colors.primaryLight,
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 10,
alignSelf: "flex-start",
marginTop: 6,
},
typeTagText: {
color: colors.primary,
},
elderModeCard: {
marginHorizontal: 16,
marginBottom: 16,
borderRadius: 12,
backgroundColor: "#FFFBEB",
},
elderModeContent: {
flexDirection: "row",
alignItems: "center",
},
elderModeIcon: {
width: 48,
height: 48,
backgroundColor: "#FEF3C7",
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
elderModeText: {
flex: 1,
marginLeft: 12,
},
elderModeTitle: {
fontWeight: "500",
color: "#92400E",
},
elderModeDesc: {
color: "#B45309",
marginTop: 2,
},
menuCard: {
margin: 16,
marginTop: 0,
borderRadius: 12,
},
menuTitle: {},
menuItem: {
flexDirection: "row",
alignItems: "center",
},
menuIcon: {
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
menuText: {
flex: 1,
marginLeft: 12,
},
menuItemTitle: {
color: colors.textPrimary,
},
menuItemDesc: {
color: colors.textHint,
marginTop: 2,
},
divider: {
marginLeft: 52,
},
logoutBtn: {
margin: 16,
padding: 16,
backgroundColor: colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.danger,
},
logoutText: {
textAlign: "center",
color: colors.danger,
fontWeight: "500",
},
version: {
textAlign: "center",
color: colors.textHint,
marginBottom: 30,
},
modal: {
backgroundColor: colors.surface,
margin: 20,
borderRadius: 16,
maxHeight: "70%",
},
modalHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
paddingRight: 8,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalTitle: {
fontWeight: "600",
color: colors.textPrimary,
},
modalList: {
maxHeight: 400,
},
medicationItem: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
medicationEnded: {
opacity: 0.6,
},
medicationHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 4,
},
medicationName: {
fontWeight: "500",
color: colors.textPrimary,
},
medicationActive: {
backgroundColor: colors.primaryLight,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
marginLeft: 8,
},
medicationCured: {
backgroundColor: "#D1FAE5",
},
medicationControlled: {
backgroundColor: "#FEF3C7",
},
medicationActiveText: {
color: colors.primary,
},
emptyMedication: {
alignItems: "center",
paddingVertical: 40,
},
emptyMedicationText: {
color: colors.textSecondary,
marginTop: 12,
},
emptyMedicationHint: {
color: colors.textHint,
marginTop: 4,
},
medicationDosage: {
color: colors.textSecondary,
marginTop: 2,
},
medicationDate: {
color: colors.textHint,
marginTop: 4,
},
medicationNotes: {
color: colors.textSecondary,
marginTop: 4,
fontStyle: "italic",
},
addMedicationBtn: {
margin: 16,
padding: 14,
backgroundColor: colors.primary,
borderRadius: 12,
alignItems: "center",
},
addMedicationText: {
color: "#fff",
fontWeight: "600",
},
editModal: {
backgroundColor: colors.surface,
margin: 20,
borderRadius: 16,
padding: 0,
},
editContent: {
padding: 16,
},
editLabel: {
color: colors.textSecondary,
marginBottom: 8,
},
editInput: {
backgroundColor: colors.surface,
},
editActions: {
flexDirection: "row",
justifyContent: "flex-end",
padding: 16,
paddingTop: 0,
gap: 12,
},
editCancelBtn: {
borderColor: colors.border,
},
editSaveBtn: {
minWidth: 80,
},
});

201
app/src/stores/authStore.ts

@ -0,0 +1,201 @@
// 认证状态管理 - 使用真实API
import { create } from "zustand";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { authApi, userApi, getToken, clearTokens } from "../api";
import type { User } from "../types";
interface LoginResult {
success: boolean;
error: string | null;
}
interface AuthState {
user: User | null;
isLoggedIn: boolean;
isLoading: boolean;
error: string | null;
login: (phone: string, code: string) => Promise<LoginResult>;
register: (
phone: string,
password: string,
code: string
) => Promise<LoginResult>;
logout: () => Promise<void>;
sendCode: (phone: string, type?: "login" | "register") => Promise<boolean>;
fetchUser: () => Promise<void>;
updateProfile: (data: {
nickname?: string;
avatar?: string;
}) => Promise<boolean>;
init: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isLoggedIn: false,
isLoading: false,
error: null,
// 发送验证码
sendCode: async (phone: string, type: "login" | "register" = "login") => {
set({ isLoading: true, error: null });
try {
const result = await authApi.sendCode(phone, type);
set({ isLoading: false });
if (result.code !== 0) {
set({ error: result.message });
return false;
}
return true;
} catch (e: any) {
set({ isLoading: false, error: e.message });
return false;
}
},
// 登录 - 返回 { success, error }
login: async (phone: string, code: string) => {
set({ isLoading: true, error: null });
try {
const result = await authApi.login({ phone, code });
if (result.code === 0 && result.data) {
// 后端直接返回用户字段,构建 user 对象
const user: User = {
id: result.data.user_id,
phone: phone,
nickname: result.data.nickname || "健康用户",
avatar: result.data.avatar || "",
survey_completed: result.data.survey_completed || false,
};
set({
user,
isLoggedIn: true,
isLoading: false,
});
// 保存用户信息到本地
await AsyncStorage.setItem("health_ai_user", JSON.stringify(user));
return { success: true, error: null };
} else {
const errorMsg = result.message || "登录失败";
set({ isLoading: false, error: errorMsg });
return { success: false, error: errorMsg };
}
} catch (e: any) {
const errorMsg = e.message || "网络错误";
set({ isLoading: false, error: errorMsg });
return { success: false, error: errorMsg };
}
},
// 注册 - 返回 { success, error }
register: async (phone: string, password: string, code: string) => {
set({ isLoading: true, error: null });
try {
const result = await authApi.register({ phone, password, code });
if (result.code === 0 && result.data) {
// 后端直接返回用户字段,构建 user 对象
const user: User = {
id: result.data.user_id,
phone: phone,
nickname: result.data.nickname || "健康用户",
avatar: result.data.avatar || "",
survey_completed: result.data.survey_completed || false,
};
set({
user,
isLoggedIn: true,
isLoading: false,
});
await AsyncStorage.setItem("health_ai_user", JSON.stringify(user));
return { success: true, error: null };
} else {
const errorMsg = result.message || "注册失败";
set({ isLoading: false, error: errorMsg });
return { success: false, error: errorMsg };
}
} catch (e: any) {
const errorMsg = e.message || "网络错误";
set({ isLoading: false, error: errorMsg });
return { success: false, error: errorMsg };
}
},
// 退出登录
logout: async () => {
await authApi.logout();
await AsyncStorage.removeItem("health_ai_user");
await AsyncStorage.removeItem("health_ai_conversations"); // 清除对话缓存
set({ user: null, isLoggedIn: false });
},
// 获取用户信息
fetchUser: async () => {
try {
const result = await userApi.getUserProfile();
if (result.code === 0 && result.data) {
set({ user: result.data });
await AsyncStorage.setItem(
"health_ai_user",
JSON.stringify(result.data)
);
}
} catch {
// 忽略错误
}
},
// 更新用户资料
updateProfile: async (data: { nickname?: string; avatar?: string }) => {
try {
const result = await userApi.updateUserProfile(data);
// 后端返回 { code: 0, message: "更新成功", data: null }
// data 为 null,需要手动更新本地状态
if (result.code === 0) {
const currentUser = get().user;
if (currentUser) {
const updatedUser = {
...currentUser,
nickname: data.nickname || currentUser.nickname,
avatar: data.avatar || currentUser.avatar,
};
set({ user: updatedUser });
await AsyncStorage.setItem(
"health_ai_user",
JSON.stringify(updatedUser)
);
}
return true;
}
return false;
} catch {
return false;
}
},
// 初始化 - 检查登录状态
init: async () => {
try {
const token = await getToken();
const userStr = await AsyncStorage.getItem("health_ai_user");
if (token && userStr) {
const user = JSON.parse(userStr);
set({ user, isLoggedIn: true });
// 后台刷新用户信息
get().fetchUser();
}
} catch {
set({ user: null, isLoggedIn: false });
}
},
// 清除错误
clearError: () => set({ error: null }),
}));

374
app/src/stores/chatStore.ts

@ -0,0 +1,374 @@
// 对话状态管理 - 完全依赖后端存储
import { create } from "zustand";
import { conversationApi } from "../api";
import type { Conversation, Message } from "../types";
import type {
ConversationItem,
ConversationDetail,
MessageItem,
} from "../api/types";
/**
* API
* - API 使 snake_case (created_at, updated_at)
* - 使 camelCase (createdAt, updatedAt)
*/
const convertConversation = (item: ConversationItem): Conversation => ({
id: String(item.id),
title: item.title,
createdAt: item.created_at,
updatedAt: item.updated_at,
});
const convertMessage = (item: MessageItem): Message => ({
id: String(item.id),
role: item.role,
content: item.content,
createdAt: item.created_at,
});
interface ChatState {
conversations: Conversation[];
currentConversation: Conversation | null;
messages: Message[];
isLoading: boolean;
isSending: boolean;
error: string | null;
fetchConversations: () => Promise<void>;
createConversation: (title?: string) => Promise<Conversation | null>;
deleteConversation: (id: string) => Promise<boolean>;
fetchMessages: (conversationId: string) => Promise<void>;
sendMessage: (
conversationId: string,
content: string,
onChunk?: (chunk: string) => void
) => Promise<Message | null>;
addConversation: (conversation: Conversation) => Promise<void>;
addMessage: (conversationId: string, message: Message) => Promise<void>;
init: () => Promise<void>;
}
export const useChatStore = create<ChatState>((set, get) => ({
conversations: [],
currentConversation: null,
messages: [],
isLoading: false,
isSending: false,
error: null,
// 获取对话列表(从后端)
fetchConversations: async () => {
// 检查是否有Token
const { getToken } = await import("../api/config");
const token = await getToken();
if (!token) {
console.log("[Chat] 未登录,跳过获取对话列表");
return;
}
set({ isLoading: true, error: null });
try {
console.log("[Chat] 从后端获取对话列表...");
const response = await conversationApi.getConversations();
if (response.code === 0 && response.data) {
// 后端返回格式可能是: data: { conversations: [...] } 或 data: [...]
let items: any[] = [];
if (Array.isArray(response.data)) {
items = response.data;
} else if (response.data.conversations && Array.isArray(response.data.conversations)) {
items = response.data.conversations;
}
const conversations = items.map(convertConversation);
console.log("[Chat] 获取到", conversations.length, "条对话");
set({ conversations, isLoading: false });
} else {
console.log("[Chat] 获取对话失败:", response.message);
set({ isLoading: false, error: response.message });
}
} catch (e: any) {
console.log("[Chat] 获取对话异常:", e.message);
set({ isLoading: false, error: e.message });
}
},
// 创建新对话
createConversation: async (title?: string) => {
try {
const response = await conversationApi.createConversation(title);
if (response.code === 0 && response.data) {
// 后端返回: data: ConversationItem
const newConv = convertConversation(response.data);
set((state) => ({
conversations: [newConv, ...state.conversations],
}));
return newConv;
}
return null;
} catch {
return null;
}
},
// 删除对话
deleteConversation: async (id: string) => {
try {
const response = await conversationApi.deleteConversation(id);
if (response.code === 0) {
set((state) => ({
conversations: state.conversations.filter((c) => c.id !== id),
}));
return true;
}
return false;
} catch {
return false;
}
},
// 获取对话消息
fetchMessages: async (conversationId: string) => {
set({ isLoading: true });
try {
const response = await conversationApi.getConversationDetail(
conversationId
);
if (response.code === 0 && response.data) {
// 后端返回: data: ConversationDetail { id, title, messages, created_at, updated_at }
const data = response.data;
const conversation = convertConversation(data);
const messages = (data.messages || []).map(convertMessage);
set({
currentConversation: conversation,
messages,
isLoading: false,
});
} else {
// 如果对话不存在(400/404),从列表移除
console.log("[Chat] 对话不存在或无效:", conversationId);
set((state) => ({
conversations: state.conversations.filter(
(c) => c.id !== conversationId
),
currentConversation: null,
messages: [],
isLoading: false,
}));
}
} catch (e) {
console.log("[Chat] 获取对话失败:", e);
set({ isLoading: false, messages: [], currentConversation: null });
}
},
// 发送消息(流式)
sendMessage: async (
conversationId: string,
content: string,
onChunk?: (chunk: string) => void
) => {
set({ isSending: true, error: null });
// 先添加用户消息
const userMessage: Message = {
id: `temp_${Date.now()}`,
role: "user",
content,
createdAt: new Date().toISOString(),
};
set((state) => ({
messages: [...state.messages, userMessage],
}));
// 创建AI消息占位
let aiContent = ""; // 完整内容(已接收)
let aiDisplayContent = ""; // 显示内容(逐字显示)
let aiThinking = "";
let aiDisplayThinking = "";
let contentQueue: string[] = []; // 待显示的内容队列
let thinkingQueue: string[] = []; // 待显示的思考队列
let isTyping = false; // 是否正在打字
const aiMessageId = `temp_ai_${Date.now()}`;
const aiMessage: Message = {
id: aiMessageId,
role: "assistant",
content: "",
thinking: "",
isThinking: false,
createdAt: new Date().toISOString(),
};
set((state) => ({
messages: [...state.messages, aiMessage],
}));
// 更新消息的辅助函数
const updateMessage = (updates: Partial<Message>) => {
set((state) => ({
messages: state.messages.map((m) =>
m.id === aiMessageId ? { ...m, ...updates } : m
),
}));
};
// 逐字显示效果 - 打字机(同时处理思考和正式回答)
const typeNextChar = () => {
// 优先处理思考内容(速度更快)
if (thinkingQueue.length > 0) {
const char = thinkingQueue.shift()!;
aiDisplayThinking += char;
updateMessage({ thinking: aiDisplayThinking });
setTimeout(typeNextChar, 10); // 思考内容快速显示
} else if (contentQueue.length > 0) {
const char = contentQueue.shift()!;
aiDisplayContent += char;
updateMessage({ content: aiDisplayContent, isThinking: false });
setTimeout(typeNextChar, 20); // 正式回答稍慢
} else {
isTyping = false;
}
};
// 启动打字效果
const startTyping = () => {
if (!isTyping && (thinkingQueue.length > 0 || contentQueue.length > 0)) {
isTyping = true;
typeNextChar();
}
};
return new Promise<Message | null>((resolve) => {
conversationApi.sendMessage(
conversationId,
content,
// onChunk - 正式回答内容(收到时立即完整显示思考,然后逐字显示回答)
(chunk) => {
// 首次收到正式回答时,立即完整显示思考内容
if (aiContent === "" && aiThinking) {
thinkingQueue = []; // 清空思考队列
aiDisplayThinking = aiThinking; // 立即显示完整思考
updateMessage({ thinking: aiDisplayThinking, isThinking: false });
}
aiContent += chunk;
// 将新内容加入队列逐字显示
for (const char of chunk) {
contentQueue.push(char);
}
startTyping();
onChunk?.(chunk);
},
// onDone - 等待正式回答显示完毕后再标记完成
(messageId) => {
const finishMessage = () => {
// 检查正式回答队列是否清空
if (contentQueue.length > 0) {
setTimeout(finishMessage, 50);
return;
}
set((state) => ({
messages: state.messages.map((m) =>
m.id === aiMessageId
? { ...m, id: messageId, content: aiContent }
: m
),
isSending: false,
}));
// 更新对话标题(如果是第一条消息)
const state = get();
if (state.messages.length <= 2) {
const title =
content.slice(0, 20) + (content.length > 20 ? "..." : "");
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? { ...c, title, updatedAt: new Date().toISOString() }
: c
),
}));
}
resolve({
id: messageId,
role: "assistant",
content: aiContent,
createdAt: new Date().toISOString(),
});
};
finishMessage();
},
// onError
(error) => {
set((state) => ({
messages: state.messages.filter((m) => m.id !== aiMessageId),
isSending: false,
error,
}));
resolve(null);
},
// onThinking - 思考过程(逐字显示)
(state, thinkingContent) => {
if (state === "start") {
updateMessage({ isThinking: true, thinking: "" });
} else if (state === "thinking" && thinkingContent) {
aiThinking += thinkingContent;
// 将思考内容加入队列逐字显示
for (const char of thinkingContent) {
thinkingQueue.push(char);
}
startTyping();
} else if (state === "end") {
// 等待思考内容显示完毕
const checkDone = () => {
if (thinkingQueue.length === 0) {
updateMessage({ isThinking: false });
} else {
setTimeout(checkDone, 50);
}
};
checkDone();
}
}
);
});
},
// 添加对话(本地,兼容旧逻辑)
addConversation: async (conversation: Conversation) => {
set((state) => ({
conversations: [conversation, ...state.conversations],
}));
},
// 添加消息(本地,兼容旧逻辑)
addMessage: async (conversationId: string, message: Message) => {
set((state) => ({
messages: [...state.messages, message],
conversations: state.conversations.map((c) =>
c.id === conversationId
? {
...c,
messages: [...(c.messages || []), message],
updatedAt: new Date().toISOString(),
title:
c.messages?.length === 0 && message.role === "user"
? message.content.slice(0, 20) +
(message.content.length > 20 ? "..." : "")
: c.title,
}
: c
),
}));
},
// 初始化 - 直接从后端加载
init: async () => {
console.log("[Chat] 初始化,从后端加载对话列表");
await get().fetchConversations();
},
}));

217
app/src/stores/constitutionStore.ts

@ -0,0 +1,217 @@
// 体质状态管理 - 使用真实API
import { create } from "zustand";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { constitutionApi } from "../api";
import type { ConstitutionResult, ConstitutionQuestion } from "../types";
interface ConstitutionState {
result: ConstitutionResult | null;
questions: ConstitutionQuestion[];
isLoading: boolean;
error: string | null;
fetchQuestions: () => Promise<ConstitutionQuestion[]>;
submitAnswers: (
answers: Record<number, number>
) => Promise<ConstitutionResult | null>;
fetchResult: () => Promise<void>;
setResult: (result: ConstitutionResult) => Promise<void>;
clearResult: () => Promise<void>;
init: () => Promise<void>;
}
export const useConstitutionStore = create<ConstitutionState>((set, get) => ({
result: null,
questions: [],
isLoading: false,
error: null,
// 获取问卷题目
fetchQuestions: async () => {
set({ isLoading: true, error: null });
try {
const response = await constitutionApi.getQuestions();
if (response.code === 0 && response.data) {
// 后端直接返回数组,而不是 { questions: [...] }
const rawQuestions = Array.isArray(response.data)
? response.data
: response.data.questions || [];
// 转换后端数据格式到前端期望的格式
// 后端: { id, question_text, options: "JSON字符串[\"没有\",\"很少\"...]", constitution_type, order_num }
// 前端: { id, question, options: [{value, label}], constitution_type, order_num }
const questions: ConstitutionQuestion[] = rawQuestions.map((q: any) => {
// 解析 options
let parsedOptions =
typeof q.options === "string" ? JSON.parse(q.options) : q.options;
// 如果是字符串数组,转换为 {value, label} 格式
// 分值: 没有=1, 很少=2, 有时=3, 经常=4, 总是=5
if (
Array.isArray(parsedOptions) &&
typeof parsedOptions[0] === "string"
) {
parsedOptions = parsedOptions.map(
(label: string, index: number) => ({
value: index + 1,
label,
})
);
}
return {
id: q.id || q.ID,
constitution_type: q.constitution_type,
question: q.question_text || q.question,
options: parsedOptions,
order_num: q.order_num,
};
});
set({ questions, isLoading: false });
return questions;
} else {
set({ isLoading: false, error: response.message });
return [];
}
} catch (e: any) {
set({ isLoading: false, error: e.message });
return [];
}
},
// 提交问卷答案
submitAnswers: async (answers: Record<number, number>) => {
set({ isLoading: true, error: null });
try {
const answersArray = Object.entries(answers).map(
([questionId, score]) => ({
question_id: parseInt(questionId),
score,
})
);
const response = await constitutionApi.submitAnswers({
answers: answersArray,
});
if (response.code === 0 && response.data) {
// 转换后端数据格式到前端期望的格式
// 后端: { primary_constitution: {type, name, score}, all_scores: [...], recommendations: {...} }
// 前端: { primaryType, scores: {type: score}, description, suggestions }
const apiResult = response.data as any;
// 构建 scores 对象
const scores: Record<string, number> = {};
if (apiResult.all_scores) {
apiResult.all_scores.forEach((item: any) => {
scores[item.type] = item.score;
});
}
// 转换为前端格式
const result: ConstitutionResult = {
id: apiResult.id || apiResult.ID,
primaryType:
apiResult.primary_constitution?.type || apiResult.primaryType,
primary_type: apiResult.primary_constitution?.type,
scores,
secondary_types: apiResult.secondary_constitutions?.map(
(s: any) => s.type
),
description: apiResult.primary_constitution?.name,
assessedAt: apiResult.assessed_at,
};
set({ result, isLoading: false });
// 缓存结果
await AsyncStorage.setItem(
"health_ai_constitution",
JSON.stringify(result)
);
return result;
} else {
set({ isLoading: false, error: response.message });
return null;
}
} catch (e: any) {
set({ isLoading: false, error: e.message });
return null;
}
},
// 从服务器获取最新结果
fetchResult: async () => {
try {
// 检查是否有Token
const { getToken } = await import("../api/config");
const token = await getToken();
if (!token) return; // 未登录时不请求
const response = await constitutionApi.getResult();
if (response.code === 0 && response.data) {
// 转换后端数据格式到前端期望的格式(与 submitAnswers 保持一致)
const apiResult = response.data as any;
// 构建 scores 对象
const scores: Record<string, number> = {};
if (apiResult.all_scores) {
apiResult.all_scores.forEach((item: any) => {
scores[item.type] = item.score;
});
}
// 转换为前端格式
const result: ConstitutionResult = {
id: apiResult.id || apiResult.ID,
primaryType:
apiResult.primary_constitution?.type || apiResult.primaryType,
primary_type: apiResult.primary_constitution?.type,
scores,
secondary_types: apiResult.secondary_constitutions?.map(
(s: any) => s.type
),
description: apiResult.primary_constitution?.name,
assessedAt: apiResult.assessed_at,
};
set({ result });
await AsyncStorage.setItem(
"health_ai_constitution",
JSON.stringify(result)
);
}
} catch {
// 忽略错误
}
},
// 设置结果(本地)
setResult: async (result: ConstitutionResult) => {
set({ result });
await AsyncStorage.setItem(
"health_ai_constitution",
JSON.stringify(result)
);
},
// 清除结果
clearResult: async () => {
set({ result: null });
await AsyncStorage.removeItem("health_ai_constitution");
},
// 初始化 - 从缓存加载
init: async () => {
try {
const cached = await AsyncStorage.getItem("health_ai_constitution");
if (cached) {
set({ result: JSON.parse(cached) });
}
// 后台刷新(fetchResult内部会检查Token)
get().fetchResult();
} catch {
// 忽略错误
}
},
}));

358
app/src/stores/healthStore.ts

@ -0,0 +1,358 @@
// 健康档案状态管理
import { create } from "zustand";
import { userApi } from "../api";
import type {
HealthProfile,
Lifestyle,
MedicalHistory,
FamilyHistory,
AllergyRecord,
} from "../api/user";
interface HealthState {
// 数据
profile: HealthProfile | null;
lifestyle: Lifestyle | null;
medicalHistory: MedicalHistory[];
familyHistory: FamilyHistory[];
allergyRecords: AllergyRecord[];
// 状态
isLoading: boolean;
error: string | null;
// 方法
fetchHealthProfile: () => Promise<void>;
fetchLifestyle: () => Promise<void>;
fetchMedicalHistory: () => Promise<void>;
fetchFamilyHistory: () => Promise<void>;
fetchAllergyRecords: () => Promise<void>;
fetchAll: () => Promise<void>;
updateHealthProfile: (data: Partial<HealthProfile>) => Promise<boolean>;
updateLifestyle: (data: Partial<Lifestyle>) => Promise<boolean>;
deleteMedicalHistory: (id: number) => Promise<boolean>;
deleteFamilyHistory: (id: number) => Promise<boolean>;
deleteAllergyRecord: (id: number) => Promise<boolean>;
addMedicalHistory: (data: {
disease_name: string;
disease_type: string;
diagnosed_date: string;
status: string;
notes?: string;
}) => Promise<boolean>;
addFamilyHistory: (data: {
relation: string;
disease_name: string;
notes?: string;
}) => Promise<boolean>;
addAllergyRecord: (data: {
allergy_type: string;
allergen: string;
severity: string;
reaction_desc?: string;
}) => Promise<boolean>;
clearError: () => void;
}
export const useHealthStore = create<HealthState>((set, get) => ({
profile: null,
lifestyle: null,
medicalHistory: [],
familyHistory: [],
allergyRecords: [],
isLoading: false,
error: null,
// 获取健康档案
fetchHealthProfile: async () => {
set({ isLoading: true, error: null });
try {
const response = await userApi.getHealthProfile();
console.log("[Health] 获取健康档案响应:", response);
if (response.code === 0 && response.data) {
// 后端返回的是嵌套结构 { profile, lifestyle, medical_history, ... }
const data = response.data as any;
// 处理病史记录 - GORM 的 ID 字段可能是大写
const medicalHistory = (
data.medical_history ||
data.medical_histories ||
[]
).map((h: any) => ({
...h,
id: h.id || h.ID, // 兼容大小写
}));
// 处理家族病史
const familyHistory = (
data.family_history ||
data.family_histories ||
[]
).map((h: any) => ({
...h,
id: h.id || h.ID,
}));
// 处理过敏记录
const allergyRecords = (data.allergy_records || []).map((r: any) => ({
...r,
id: r.id || r.ID,
}));
console.log("[Health] 处理后的病史记录:", medicalHistory);
set({
profile: data.profile || data, // 兼容两种格式
medicalHistory,
familyHistory,
allergyRecords,
isLoading: false,
});
} else {
set({ isLoading: false, error: response.message });
}
} catch (e: any) {
set({ isLoading: false, error: e.message });
}
},
// 获取生活习惯
fetchLifestyle: async () => {
try {
const response = await userApi.getLifestyle();
if (response.code === 0 && response.data) {
set({ lifestyle: response.data });
}
} catch (e: any) {
console.log("[Health] 获取生活习惯失败:", e.message);
}
},
// 获取病史
fetchMedicalHistory: async () => {
try {
const response = await userApi.getMedicalHistory();
if (response.code === 0 && response.data) {
set({ medicalHistory: response.data });
}
} catch (e: any) {
console.log("[Health] 获取病史失败:", e.message);
}
},
// 获取家族病史
fetchFamilyHistory: async () => {
try {
const response = await userApi.getFamilyHistory();
if (response.code === 0 && response.data) {
set({ familyHistory: response.data });
}
} catch (e: any) {
console.log("[Health] 获取家族病史失败:", e.message);
}
},
// 获取过敏记录
fetchAllergyRecords: async () => {
try {
const response = await userApi.getAllergyRecords();
if (response.code === 0 && response.data) {
set({ allergyRecords: response.data });
}
} catch (e: any) {
console.log("[Health] 获取过敏记录失败:", e.message);
}
},
// 获取全部数据
fetchAll: async () => {
set({ isLoading: true, error: null });
try {
await Promise.all([get().fetchHealthProfile(), get().fetchLifestyle()]);
} finally {
set({ isLoading: false });
}
},
// 更新健康档案
updateHealthProfile: async (data: Partial<HealthProfile>) => {
set({ isLoading: true, error: null });
try {
const response = await userApi.updateHealthProfile(data);
console.log("[Health] 更新健康档案响应:", response);
if (response.code === 0) {
// 更新成功,如果有返回数据则使用,否则直接标记成功
if (response.data) {
set({ profile: response.data, isLoading: false });
} else {
set({ isLoading: false });
}
return true;
}
set({ isLoading: false, error: response.message });
return false;
} catch (e: any) {
set({ isLoading: false, error: e.message });
return false;
}
},
// 更新生活习惯
updateLifestyle: async (data: Partial<Lifestyle>) => {
set({ isLoading: true, error: null });
try {
const response = await userApi.updateLifestyle(data);
console.log("[Health] 更新生活习惯响应:", response);
if (response.code === 0) {
// 更新成功,如果有返回数据则使用,否则直接标记成功
if (response.data) {
set({ lifestyle: response.data, isLoading: false });
} else {
set({ isLoading: false });
}
return true;
}
set({ isLoading: false, error: response.message });
return false;
} catch (e: any) {
set({ isLoading: false, error: e.message });
return false;
}
},
// 删除病史记录
deleteMedicalHistory: async (id: number) => {
try {
console.log("[Health] 删除病史记录, id:", id);
const response = await userApi.deleteMedicalHistory(id);
console.log("[Health] 删除病史记录响应:", response);
if (response.code === 0) {
set((state) => ({
medicalHistory: state.medicalHistory.filter((h) => h.id !== id),
}));
return true;
}
console.error("[Health] 删除病史记录失败:", response.message);
return false;
} catch (e: any) {
console.error("[Health] 删除病史记录异常:", e.message);
return false;
}
},
// 删除家族病史
deleteFamilyHistory: async (id: number) => {
try {
console.log("[Health] 删除家族病史, id:", id);
const response = await userApi.deleteFamilyHistory(id);
console.log("[Health] 删除家族病史响应:", response);
if (response.code === 0) {
set((state) => ({
familyHistory: state.familyHistory.filter((h) => h.id !== id),
}));
return true;
}
console.error("[Health] 删除家族病史失败:", response.message);
return false;
} catch (e: any) {
console.error("[Health] 删除家族病史异常:", e.message);
return false;
}
},
// 删除过敏记录
deleteAllergyRecord: async (id: number) => {
try {
console.log("[Health] 删除过敏记录, id:", id);
const response = await userApi.deleteAllergyRecord(id);
console.log("[Health] 删除过敏记录响应:", response);
if (response.code === 0) {
set((state) => ({
allergyRecords: state.allergyRecords.filter((r) => r.id !== id),
}));
return true;
}
console.error("[Health] 删除过敏记录失败:", response.message);
return false;
} catch (e: any) {
console.error("[Health] 删除过敏记录异常:", e.message);
return false;
}
},
// 添加病史记录
addMedicalHistory: async (data) => {
try {
const response = await userApi.addMedicalHistory(data);
console.log("[Health] 添加病史记录响应:", response);
if (response.code === 0) {
// 添加成功,如果有返回数据则加入列表
if (response.data) {
set((state) => ({
medicalHistory: [...state.medicalHistory, response.data!],
}));
}
// 重新获取列表以确保数据同步
await get().fetchHealthProfile();
return true;
}
return false;
} catch (e: any) {
console.error("[Health] 添加病史记录失败:", e.message);
return false;
}
},
// 添加家族病史
addFamilyHistory: async (data) => {
try {
const response = await userApi.addFamilyHistory(data);
console.log("[Health] 添加家族病史响应:", response);
if (response.code === 0) {
// 添加成功,如果有返回数据则加入列表
if (response.data) {
set((state) => ({
familyHistory: [...state.familyHistory, response.data!],
}));
}
// 重新获取列表以确保数据同步
await get().fetchHealthProfile();
return true;
}
return false;
} catch (e: any) {
console.error("[Health] 添加家族病史失败:", e.message);
return false;
}
},
// 添加过敏记录
addAllergyRecord: async (data) => {
try {
const response = await userApi.addAllergyRecord(data);
console.log("[Health] 添加过敏记录响应:", response);
if (response.code === 0) {
// 添加成功,如果有返回数据则加入列表
if (response.data) {
set((state) => ({
allergyRecords: [...state.allergyRecords, response.data!],
}));
}
// 重新获取列表以确保数据同步
await get().fetchHealthProfile();
return true;
}
return false;
} catch (e: any) {
console.error("[Health] 添加过敏记录失败:", e.message);
return false;
}
},
// 清除错误
clearError: () => set({ error: null }),
}));

39
app/src/stores/settingsStore.ts

@ -0,0 +1,39 @@
import { create } from 'zustand'
import AsyncStorage from '@react-native-async-storage/async-storage'
interface SettingsState {
elderMode: boolean
toggleElderMode: () => Promise<void>
init: () => Promise<void>
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
elderMode: false,
toggleElderMode: async () => {
const newValue = !get().elderMode
await AsyncStorage.setItem('elder_mode', JSON.stringify(newValue))
set({ elderMode: newValue })
},
init: async () => {
try {
const saved = await AsyncStorage.getItem('elder_mode')
if (saved) {
set({ elderMode: JSON.parse(saved) })
}
} catch (error) {
console.error('Failed to load settings:', error)
}
}
}))
// 适老模式字体大小
export const getFontSize = (elderMode: boolean, baseSize: number): number => {
return elderMode ? baseSize * 1.25 : baseSize
}
// 适老模式间距
export const getSpacing = (elderMode: boolean, baseSpacing: number): number => {
return elderMode ? baseSpacing * 1.2 : baseSpacing
}

44
app/src/theme/index.ts

@ -0,0 +1,44 @@
import { MD3LightTheme, configureFonts } from 'react-native-paper'
export const theme = {
...MD3LightTheme,
colors: {
...MD3LightTheme.colors,
primary: '#10B981',
primaryContainer: '#ECFDF5',
secondary: '#3B82F6',
secondaryContainer: '#DBEAFE',
tertiary: '#8B5CF6',
tertiaryContainer: '#EDE9FE',
error: '#EF4444',
errorContainer: '#FEE2E2',
background: '#F3F4F6',
surface: '#FFFFFF',
surfaceVariant: '#F3F4F6',
outline: '#E5E7EB',
onPrimary: '#FFFFFF',
onSecondary: '#FFFFFF',
onBackground: '#1F2937',
onSurface: '#1F2937',
onSurfaceVariant: '#6B7280'
}
}
export const colors = {
primary: '#10B981',
primaryLight: '#ECFDF5',
secondary: '#3B82F6',
secondaryLight: '#DBEAFE',
tertiary: '#8B5CF6',
tertiaryLight: '#EDE9FE',
danger: '#EF4444',
dangerLight: '#FEE2E2',
warning: '#F59E0B',
warningLight: '#FEF3C7',
textPrimary: '#1F2937',
textSecondary: '#6B7280',
textHint: '#9CA3AF',
background: '#F3F4F6',
surface: '#FFFFFF',
border: '#E5E7EB'
}

126
app/src/types/index.ts

@ -0,0 +1,126 @@
// 用户类型
export interface User {
id: number;
phone: string;
nickname: string;
avatar: string;
survey_completed: boolean; // API 返回下划线命名
}
// 体质类型 (API返回的英文key)
export type ConstitutionType =
| "balanced"
| "qi_deficiency"
| "yang_deficiency"
| "yin_deficiency"
| "phlegm_dampness"
| "damp_heat"
| "blood_stasis"
| "qi_stagnation"
| "special"
// 兼容旧的中文拼音key
| "pinghe"
| "qixu"
| "yangxu"
| "yinxu"
| "tanshi"
| "shire"
| "xueyu"
| "qiyu"
| "tebing";
// 体质问卷题目
export interface ConstitutionQuestion {
id: number;
constitution_type: ConstitutionType;
question: string;
options: { value: number; label: string }[];
order_num: number;
}
// 体质评估结果
export interface ConstitutionResult {
id?: number;
primaryType: ConstitutionType; // 兼容旧字段
primary_type?: ConstitutionType; // API字段
scores: Record<string, number>;
secondary_types?: ConstitutionType[];
description?: string;
suggestions?: string[];
recommendations?: {
diet: string[];
lifestyle: string[];
exercise: string[];
emotion: string[];
};
assessed_at?: string;
assessedAt?: string; // 兼容旧字段
}
// 对话消息
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
createdAt: string;
created_at?: string;
thinking?: string; // AI 思考过程内容
isThinking?: boolean; // 是否正在思考中
}
// 对话
export interface Conversation {
id: string;
title: string;
messages?: Message[];
createdAt: string;
updatedAt: string;
created_at?: string;
updated_at?: string;
}
// 产品
export interface Product {
id: number;
name: string;
category: string;
description: string;
efficacy: string;
suitable: string;
price: number;
image_url: string;
mall_url: string;
imageUrl?: string; // 兼容旧字段
mallUrl?: string;
priority?: number;
reason?: string;
}
// 导航类型
export type RootStackParamList = {
Login: undefined;
Main: undefined;
};
export type MainTabParamList = {
Home: undefined;
Chat: undefined;
Constitution: undefined;
Profile: undefined;
};
export type ChatStackParamList = {
ChatList: undefined;
ChatDetail: { id: string };
};
export type ConstitutionStackParamList = {
ConstitutionHome: undefined;
ConstitutionTest: undefined;
ConstitutionResult: undefined;
};
export type HomeStackParamList = {
HomeMain: undefined;
HealthNews: undefined;
};

6
app/tsconfig.json

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

5
mall/.env.example

@ -0,0 +1,5 @@
# 后端 API 地址(与健康助手共用同一后端)
VITE_API_BASE_URL=http://localhost:8080
# 健康助手前端地址(用于跨项目跳转)
VITE_HEALTH_AI_URL=http://localhost:5173

5
web/.env.example

@ -0,0 +1,5 @@
# 后端 API 地址
VITE_API_BASE_URL=http://localhost:8080
# 商城前端地址(用于跨项目跳转)
VITE_MALL_URL=http://localhost:5174
Loading…
Cancel
Save