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