Browse Source
- app/ 从 gitlink(160000) 转为常规跟踪文件(47个),解决 clone 后 app 为空 - 新增 web/.env.example、mall/.env.example 环境变量模板 - .gitignore 放行 .env.example,新增 backend/healthapi/data/ 忽略 - README.md 完全重写:项目结构、技术栈、环境要求、从零搭建步骤、 一键启动、环境变量配置、测试命令、常见问题排查、架构图 Made-with: Cursormaster
53 changed files with 24664 additions and 47 deletions
@ -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 |
|||
# 启动 Web 原型 |
|||
cd web && npm run dev |
|||
git clone <仓库地址> healthApps |
|||
cd healthApps |
|||
``` |
|||
|
|||
### 2. 后端启动 |
|||
|
|||
```bash |
|||
cd backend/healthapi |
|||
|
|||
# 启动 APP 原型 |
|||
cd app && npx expo start |
|||
# 安装 Go 依赖 |
|||
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` |
|||
- **验证码**: `123456` |
|||
访问 `http://localhost:8080/api/health` 验证是否正常。 |
|||
|
|||
## 项目结构 |
|||
### 3. Web 前端启动 |
|||
|
|||
```bash |
|||
cd web |
|||
|
|||
# 创建环境变量(从模板复制,通常不需要修改) |
|||
cp .env.example .env |
|||
|
|||
# 安装依赖 |
|||
npm install |
|||
|
|||
# 启动开发服务 |
|||
npm run dev |
|||
``` |
|||
healthApps/ |
|||
├── web/ # Web 原型 (Vue 3 + Vite) |
|||
├── app/ # APP 原型 (React Native + Expo) |
|||
├── server/ # 后端服务 (Go + Gin) |
|||
├── scripts/ # 启动脚本 |
|||
│ ├── start-web.bat |
|||
│ ├── start-app.bat |
|||
│ └── start-all.bat |
|||
├── start.bat # 主启动入口 |
|||
├── design.md # 项目设计文档 |
|||
└── TODOS/ # 开发任务文档 |
|||
|
|||
打开 http://localhost:5173 |
|||
|
|||
### 4. 商城前端启动 |
|||
|
|||
```bash |
|||
cd mall |
|||
|
|||
# 创建环境变量 |
|||
cp .env.example .env |
|||
|
|||
# 安装依赖 |
|||
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 | |
|||
| APP | React Native + Expo + Paper | 8081 | |
|||
| 后端 | Go + Gin + SQLite | 8080 | |
|||
使用根目录提供的启动脚本,自动处理端口占用和依赖安装。 |
|||
|
|||
### Windows |
|||
|
|||
```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` | |
|||
|
|||
> 后端首次启动会自动创建此测试用户。 |
|||
|
|||
--- |
|||
|
|||
## 功能模块 |
|||
|
|||
- **用户登录** - 手机号验证码登录(模拟) |
|||
- **首页** - 体质概览、快捷入口、健康提示 |
|||
- **体质测试** - 20道问卷,本地计算体质类型 |
|||
- **AI问答** - 模拟AI健康咨询对话 |
|||
- **个人中心** - 用户信息、健康档案 |
|||
### 健康 AI 助手 (Web) |
|||
|
|||
- 手机号登录(验证码 / 密码) |
|||
- 体质辨识测评(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/) | 开发任务与规划 | |
|||
|
|||
@ -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 |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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" |
|||
} |
|||
} |
|||
} |
|||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
@ -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); |
|||
File diff suppressed because it is too large
@ -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 |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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, |
|||
} |
|||
@ -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"); |
|||
} |
|||
@ -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 } |
|||
); |
|||
} |
|||
@ -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' |
|||
@ -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 }) |
|||
} |
|||
@ -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' }) |
|||
} |
|||
@ -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; |
|||
@ -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); |
|||
} |
|||
@ -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 |
|||
@ -0,0 +1,2 @@ |
|||
export { AlertProvider, useAlert } from './AlertProvider' |
|||
export type { AlertButton } from './AlertProvider' |
|||
@ -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) |
|||
}) |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
export * from './user' |
|||
export * from './constitution' |
|||
export * from './chat' |
|||
export * from './products' |
|||
@ -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: '活血化瘀' |
|||
} |
|||
] |
|||
@ -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了解自己的体质类型,可以更有针对性地进行养生调理。' |
|||
} |
|||
] |
|||
@ -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)) |
|||
} |
|||
@ -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) |
|||
}) |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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' |
|||
} |
|||
}) |
|||
@ -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", |
|||
}, |
|||
}); |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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 |
|||
} |
|||
}) |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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 |
|||
} |
|||
}) |
|||
File diff suppressed because it is too large
@ -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, |
|||
}, |
|||
}); |
|||
@ -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 }), |
|||
})); |
|||
@ -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(); |
|||
}, |
|||
})); |
|||
@ -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 { |
|||
// 忽略错误
|
|||
} |
|||
}, |
|||
})); |
|||
@ -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 }), |
|||
})); |
|||
@ -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 |
|||
} |
|||
@ -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' |
|||
} |
|||
@ -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; |
|||
}; |
|||
@ -0,0 +1,6 @@ |
|||
{ |
|||
"extends": "expo/tsconfig.base", |
|||
"compilerOptions": { |
|||
"strict": true |
|||
} |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
# 后端 API 地址(与健康助手共用同一后端) |
|||
VITE_API_BASE_URL=http://localhost:8080 |
|||
|
|||
# 健康助手前端地址(用于跨项目跳转) |
|||
VITE_HEALTH_AI_URL=http://localhost:5173 |
|||
@ -0,0 +1,5 @@ |
|||
# 后端 API 地址 |
|||
VITE_API_BASE_URL=http://localhost:8080 |
|||
|
|||
# 商城前端地址(用于跨项目跳转) |
|||
VITE_MALL_URL=http://localhost:5174 |
|||
Loading…
Reference in new issue