healthapp
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

11 KiB

05-体质辨识页面

目标

实现 APP 端中医体质辨识问卷和结果展示功能。


UI 设计参考

参考设计稿:files/ui/体质页.pngfiles/ui/体质检测.pngfiles/ui/体质分析.png

体质首页

区域 设计要点
顶部卡片 绿色渐变背景 #10B981 → #2EC4B6,圆角 16px
标题 "中医体质自测",白色 20px 粗体
测试说明 白色卡片,3步骤(绿色序号圆圈)
按钮 绿色全宽按钮,圆角 24px

问卷页面

区域 设计要点
进度显示 右上角 "1/65",绿色进度条高度 4px
问题标签 绿色背景 #DCFCE7,文字 #10B981
选项按钮 白色背景,边框 #E5E7EB,选中时绿色边框 + 背景
底部提示 浅绿色背景 #ECFDF5,info 图标

结果页面

区域 设计要点
顶部 绿色渐变 + 人体轮廓 + 分享按钮
体质名称 白色大字 32px,分数徽章
雷达图 白色卡片,颜色 #10B981,透明填充
调理建议 2×2 网格,各带彩色图标背景

调理建议卡片

类型 图标背景 图标颜色
起居 #EDE9FE #8B5CF6
饮食 #CCFBF1 #14B8A6
运动 #EDE9FE #8B5CF6
情志 #FCE7F3 #EC4899

实现要点

  1. 问卷 UI:使用卡片+按钮组形式展示题目和选项
  2. 进度条:使用 ProgressBar 显示答题进度
  3. 雷达图:使用 react-native-gifted-chartsvictory-native
  4. 结果展示:使用 Tab 切换调养建议

关键代码示例

问卷页面

// src/screens/constitution/ConstitutionQuestionsScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, StyleSheet } from 'react-native'
import { Button, ProgressBar, Card } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { getQuestions, submitAssessment } from '../../api/constitution'
import type { Question } from '../../types'

const options = ['没有', '很少', '有时', '经常', '总是']

const ConstitutionQuestionsScreen = () => {
  const navigation = useNavigation()
  const [questions, setQuestions] = useState<Question[]>([])
  const [currentIndex, setCurrentIndex] = useState(0)
  const [answers, setAnswers] = useState<Record<number, number>>({})
  const [loading, setLoading] = useState(true)
  const [submitting, setSubmitting] = useState(false)

  useEffect(() => {
    loadQuestions()
  }, [])

  const loadQuestions = async () => {
    try {
      const data = await getQuestions()
      setQuestions(data)
    } finally {
      setLoading(false)
    }
  }

  const currentQuestion = questions[currentIndex]
  const progress = questions.length > 0 ? (currentIndex + 1) / questions.length : 0

  const selectOption = (score: number) => {
    setAnswers({ ...answers, [currentQuestion.id]: score })
  }

  const handleNext = () => {
    if (currentIndex < questions.length - 1) {
      setCurrentIndex(currentIndex + 1)
    }
  }

  const handlePrev = () => {
    if (currentIndex > 0) {
      setCurrentIndex(currentIndex - 1)
    }
  }

  const handleSubmit = async () => {
    setSubmitting(true)
    try {
      const answerList = Object.entries(answers).map(([qid, score]) => ({
        question_id: parseInt(qid),
        score,
      }))
      await submitAssessment(answerList)
      navigation.navigate('ConstitutionResult')
    } finally {
      setSubmitting(false)
    }
  }

  if (loading || !currentQuestion) {
    return (
      <View style={styles.loadingContainer}>
        <Text>加载中...</Text>
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <View style={styles.progressContainer}>
        <Text style={styles.progressText}>
          {currentIndex + 1} / {questions.length}
        </Text>
        <ProgressBar progress={progress} color="#667eea" />
      </View>

      <ScrollView style={styles.content}>
        <Card style={styles.questionCard}>
          <Card.Content>
            <Text style={styles.questionText}>
              {currentIndex + 1}. {currentQuestion.question_text}
            </Text>

            <View style={styles.options}>
              {options.map((option, index) => (
                <Button
                  key={index}
                  mode={answers[currentQuestion.id] === index + 1 ? 'contained' : 'outlined'}
                  onPress={() => selectOption(index + 1)}
                  style={styles.optionButton}
                >
                  {option}
                </Button>
              ))}
            </View>
          </Card.Content>
        </Card>
      </ScrollView>

      <View style={styles.navButtons}>
        <Button
          mode="outlined"
          onPress={handlePrev}
          disabled={currentIndex === 0}
        >
          上一题
        </Button>

        {currentIndex < questions.length - 1 ? (
          <Button
            mode="contained"
            onPress={handleNext}
            disabled={!answers[currentQuestion.id]}
          >
            下一题
          </Button>
        ) : (
          <Button
            mode="contained"
            onPress={handleSubmit}
            loading={submitting}
            disabled={Object.keys(answers).length < questions.length}
          >
            提交
          </Button>
        )}
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  loadingContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  progressContainer: {
    padding: 16,
    backgroundColor: '#fff',
  },
  progressText: {
    textAlign: 'center',
    marginBottom: 8,
    color: '#666',
  },
  content: {
    flex: 1,
    padding: 16,
  },
  questionCard: {
    marginBottom: 16,
  },
  questionText: {
    fontSize: 18,
    lineHeight: 28,
    marginBottom: 20,
  },
  options: {
    gap: 12,
  },
  optionButton: {
    marginBottom: 8,
  },
  navButtons: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    padding: 16,
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#eee',
  },
})

export default ConstitutionQuestionsScreen

结果页面(使用 victory-native 雷达图)

// src/screens/constitution/ConstitutionResultScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, StyleSheet } from 'react-native'
import { Card, Chip, Button } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { getLatestResult } from '../../api/constitution'
import type { ConstitutionResult } from '../../types'

const ConstitutionResultScreen = () => {
  const navigation = useNavigation()
  const [result, setResult] = useState<ConstitutionResult | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    loadResult()
  }, [])

  const loadResult = async () => {
    try {
      const data = await getLatestResult()
      setResult(data)
    } finally {
      setLoading(false)
    }
  }

  if (loading || !result) {
    return (
      <View style={styles.loadingContainer}>
        <Text>加载中...</Text>
      </View>
    )
  }

  return (
    <ScrollView style={styles.container}>
      {/* 主要体质 */}
      <Card style={styles.primaryCard}>
        <Card.Content>
          <Text style={styles.sectionTitle}>您的体质类型</Text>
          <Chip style={styles.primaryChip} textStyle={styles.primaryChipText}>
            {result.primary_constitution.name}
          </Chip>
          <Text style={styles.description}>
            {result.primary_constitution.description}
          </Text>
        </Card.Content>
      </Card>

      {/* 所有体质得分 */}
      <Card style={styles.card}>
        <Card.Content>
          <Text style={styles.sectionTitle}>体质得分</Text>
          {result.all_scores.map((score) => (
            <View key={score.type} style={styles.scoreItem}>
              <Text style={styles.scoreName}>{score.name}</Text>
              <View style={styles.scoreBar}>
                <View
                  style={[styles.scoreBarFill, { width: `${score.score}%` }]}
                />
              </View>
              <Text style={styles.scoreValue}>{score.score.toFixed(0)}</Text>
            </View>
          ))}
        </Card.Content>
      </Card>

      {/* 调养建议 */}
      <Card style={styles.card}>
        <Card.Content>
          <Text style={styles.sectionTitle}>调养建议</Text>
          {Object.entries(result.recommendations).map(([type, recs]) => (
            <View key={type} style={styles.recSection}>
              {Object.entries(recs).map(([key, value]) => (
                <View key={key} style={styles.recItem}>
                  <Text style={styles.recTitle}>
                    {key === 'diet' ? '饮食' : key === 'lifestyle' ? '起居' : key === 'exercise' ? '运动' : '情志'}
                  </Text>
                  <Text style={styles.recText}>{value}</Text>
                </View>
              ))}
            </View>
          ))}
        </Card.Content>
      </Card>

      <View style={styles.actions}>
        <Button
          mode="contained"
          onPress={() => navigation.navigate('ChatTab')}
        >
          开始 AI 问诊
        </Button>
      </View>
    </ScrollView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
    padding: 16,
  },
  loadingContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  primaryCard: {
    marginBottom: 16,
    alignItems: 'center',
  },
  card: {
    marginBottom: 16,
  },
  sectionTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 12,
  },
  primaryChip: {
    backgroundColor: '#667eea',
    alignSelf: 'center',
    marginBottom: 12,
  },
  primaryChipText: {
    color: '#fff',
    fontSize: 16,
  },
  description: {
    textAlign: 'center',
    color: '#666',
    lineHeight: 22,
  },
  scoreItem: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 12,
  },
  scoreName: {
    width: 60,
    fontSize: 13,
  },
  scoreBar: {
    flex: 1,
    height: 8,
    backgroundColor: '#e0e0e0',
    borderRadius: 4,
    marginHorizontal: 8,
  },
  scoreBarFill: {
    height: '100%',
    backgroundColor: '#667eea',
    borderRadius: 4,
  },
  scoreValue: {
    width: 30,
    textAlign: 'right',
    fontSize: 13,
  },
  recSection: {
    marginBottom: 16,
  },
  recItem: {
    backgroundColor: '#f9f9f9',
    padding: 12,
    borderRadius: 8,
    marginBottom: 8,
  },
  recTitle: {
    fontWeight: 'bold',
    marginBottom: 4,
  },
  recText: {
    color: '#666',
    lineHeight: 20,
  },
  actions: {
    padding: 16,
    alignItems: 'center',
  },
})

export default ConstitutionResultScreen

需要创建的文件

文件路径 说明
src/api/constitution.ts 体质 API
src/screens/constitution/ConstitutionHomeScreen.tsx 测评首页
src/screens/constitution/ConstitutionQuestionsScreen.tsx 问卷页面
src/screens/constitution/ConstitutionResultScreen.tsx 结果页面

验收标准

  • 问卷正常加载和答题
  • 进度条显示正确
  • 提交后显示结果
  • 调养建议完整显示

预计耗时

35-45 分钟


下一步

完成后进入 04-APP开发/06-AI对话页面.md