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.
 
 
 
 
 
 

16 KiB

05-体质辨识页面(原型)

目标

实现 APP 端中医体质辨识问卷和结果展示,使用本地模拟数据计算结果。


UI 设计参考

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


页面组成

  1. 体质首页 - 介绍页面,引导用户开始测试
  2. 问卷页面 - 60道题目,逐题作答
  3. 结果页面 - 显示体质类型、雷达图、调养建议

前置要求

  • 导航配置完成
  • 模拟数据服务已创建(src/mock/constitution.ts

实施步骤

步骤 1:体质首页

创建 src/screens/constitution/ConstitutionHomeScreen.tsx

import React from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { Text, Card, Button } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionNames, constitutionDescriptions } from '../../mock/constitution'

const ConstitutionHomeScreen = () => {
  const navigation = useNavigation<any>()
  const { result } = useConstitutionStore()

  const steps = [
    { icon: 'clipboard-text', title: '回答问卷', desc: '60道题目,约10分钟' },
    { icon: 'calculator', title: '智能分析', desc: '根据答案计算体质' },
    { icon: 'file-document', title: '获取报告', desc: '体质类型和调养建议' },
  ]

  return (
    <ScrollView style={styles.container}>
      {/* 已有结果时显示 */}
      {result && (
        <Card style={styles.resultCard}>
          <Card.Content>
            <View style={styles.resultHeader}>
              <Icon name="check-circle" size={24} color="#10B981" />
              <Text style={styles.resultTitle}>您已完成体质测评</Text>
            </View>
            <View style={styles.resultBody}>
              <Text style={styles.resultType}>
                {constitutionNames[result.primaryType]}
              </Text>
              <Text style={styles.resultDesc}>
                {constitutionDescriptions[result.primaryType].description}
              </Text>
            </View>
            <Button
              mode="contained"
              onPress={() => navigation.navigate('ConstitutionResult')}
              buttonColor="#10B981"
              style={styles.resultButton}
            >
              查看详细报告
            </Button>
          </Card.Content>
        </Card>
      )}

      {/* 介绍卡片 */}
      <Card style={styles.introCard}>
        <Card.Content>
          <Text style={styles.introTitle}>中医体质自测</Text>
          <Text style={styles.introDesc}>
            中医体质辨识是以中医理论为指导,根据人体生理特点分为9种基本体质类型。
            了解自己的体质类型,有助于选择适合的养生方法。
          </Text>
        </Card.Content>
      </Card>

      {/* 步骤说明 */}
      <View style={styles.steps}>
        {steps.map((step, index) => (
          <View key={index} style={styles.stepItem}>
            <View style={styles.stepIcon}>
              <Icon name={step.icon} size={24} color="#10B981" />
            </View>
            <View style={styles.stepContent}>
              <Text style={styles.stepTitle}>{step.title}</Text>
              <Text style={styles.stepDesc}>{step.desc}</Text>
            </View>
            {index < steps.length - 1 && <View style={styles.stepLine} />}
          </View>
        ))}
      </View>

      {/* 开始按钮 */}
      <Button
        mode="contained"
        onPress={() => navigation.navigate('ConstitutionQuestions')}
        buttonColor="#10B981"
        style={styles.startButton}
        contentStyle={styles.startButtonContent}
      >
        {result ? '重新测评' : '开始测评'}
      </Button>

      <Text style={styles.note}>
        建议每3-6个月重新测评一次,以跟踪体质变化
      </Text>
    </ScrollView>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#F3F4F6', padding: 16 },
  resultCard: { marginBottom: 16, borderRadius: 12, borderLeftWidth: 4, borderLeftColor: '#10B981' },
  resultHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
  resultTitle: { fontSize: 16, fontWeight: '600', marginLeft: 8, color: '#10B981' },
  resultBody: { marginBottom: 12 },
  resultType: { fontSize: 24, fontWeight: 'bold', color: '#1F2937' },
  resultDesc: { fontSize: 14, color: '#6B7280', marginTop: 4 },
  resultButton: { borderRadius: 8 },
  introCard: { marginBottom: 16, borderRadius: 12 },
  introTitle: { fontSize: 20, fontWeight: 'bold', color: '#1F2937', marginBottom: 8 },
  introDesc: { fontSize: 14, color: '#6B7280', lineHeight: 22 },
  steps: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 16 },
  stepItem: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 16 },
  stepIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#ECFDF5', justifyContent: 'center', alignItems: 'center' },
  stepContent: { flex: 1, marginLeft: 12 },
  stepTitle: { fontSize: 16, fontWeight: '600', color: '#1F2937' },
  stepDesc: { fontSize: 13, color: '#6B7280', marginTop: 2 },
  stepLine: { position: 'absolute', left: 24, top: 48, width: 1, height: 16, backgroundColor: '#E5E7EB' },
  startButton: { borderRadius: 24, marginBottom: 12 },
  startButtonContent: { paddingVertical: 8 },
  note: { fontSize: 12, color: '#9CA3AF', textAlign: 'center' },
})

export default ConstitutionHomeScreen

步骤 2:问卷页面

创建 src/screens/constitution/ConstitutionQuestionsScreen.tsx

import React, { useState } from 'react'
import { View, ScrollView, StyleSheet, Alert } from 'react-native'
import { Text, Button, ProgressBar, Card } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionQuestions, calculateConstitution } from '../../mock/constitution'

const ConstitutionQuestionsScreen = () => {
  const navigation = useNavigation<any>()
  const { setResult } = useConstitutionStore()
  
  const [currentIndex, setCurrentIndex] = useState(0)
  const [answers, setAnswers] = useState<Record<number, number>>({})

  const questions = constitutionQuestions
  const currentQuestion = questions[currentIndex]
  const progress = (currentIndex + 1) / questions.length
  const isLastQuestion = currentIndex === questions.length - 1

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

  const handleNext = () => {
    if (!answers[currentQuestion.id]) {
      Alert.alert('提示', '请选择一个选项')
      return
    }
    if (currentIndex < questions.length - 1) {
      setCurrentIndex(currentIndex + 1)
    }
  }

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

  const handleSubmit = () => {
    if (Object.keys(answers).length < questions.length) {
      Alert.alert('提示', '请完成所有题目')
      return
    }
    
    // 本地计算结果
    const result = calculateConstitution(answers)
    setResult(result)
    navigation.navigate('ConstitutionResult')
  }

  return (
    <View style={styles.container}>
      {/* 进度条 */}
      <View style={styles.progressContainer}>
        <Text style={styles.progressText}>
           {currentIndex + 1}  /  {questions.length} 
        </Text>
        <ProgressBar progress={progress} color="#10B981" style={styles.progressBar} />
      </View>

      {/* 问题卡片 */}
      <ScrollView style={styles.content}>
        <Card style={styles.questionCard}>
          <Card.Content>
            <Text style={styles.questionText}>{currentQuestion.question}</Text>
            
            <View style={styles.options}>
              {currentQuestion.options.map((option) => (
                <Button
                  key={option.value}
                  mode={answers[currentQuestion.id] === option.value ? 'contained' : 'outlined'}
                  onPress={() => selectOption(option.value)}
                  style={styles.optionButton}
                  buttonColor={answers[currentQuestion.id] === option.value ? '#10B981' : undefined}
                >
                  {option.label}
                </Button>
              ))}
            </View>
          </Card.Content>
        </Card>
      </ScrollView>

      {/* 导航按钮 */}
      <View style={styles.navButtons}>
        <Button
          mode="outlined"
          onPress={handlePrev}
          disabled={currentIndex === 0}
          style={styles.navButton}
        >
          上一题
        </Button>
        
        {isLastQuestion ? (
          <Button
            mode="contained"
            onPress={handleSubmit}
            buttonColor="#10B981"
            style={styles.navButton}
          >
            提交
          </Button>
        ) : (
          <Button
            mode="contained"
            onPress={handleNext}
            buttonColor="#10B981"
            style={styles.navButton}
          >
            下一题
          </Button>
        )}
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#F3F4F6' },
  progressContainer: { padding: 16, backgroundColor: '#fff' },
  progressText: { textAlign: 'center', marginBottom: 8, color: '#6B7280' },
  progressBar: { height: 6, borderRadius: 3 },
  content: { flex: 1, padding: 16 },
  questionCard: { borderRadius: 12 },
  questionText: { fontSize: 18, lineHeight: 28, color: '#1F2937', marginBottom: 20 },
  options: { gap: 12 },
  optionButton: { marginBottom: 8, borderRadius: 8 },
  navButtons: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#E5E7EB' },
  navButton: { flex: 1, marginHorizontal: 8, borderRadius: 8 },
})

export default ConstitutionQuestionsScreen

步骤 3:结果页面

创建 src/screens/constitution/ConstitutionResultScreen.tsx

import React from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { Text, Card, Chip, Button } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import { useConstitutionStore } from '../../stores/useConstitutionStore'
import { constitutionNames, constitutionDescriptions } from '../../mock/constitution'
import { getProductsByConstitution } from '../../mock/products'

const ConstitutionResultScreen = () => {
  const navigation = useNavigation<any>()
  const { result } = useConstitutionStore()

  if (!result) {
    return (
      <View style={styles.emptyContainer}>
        <Text>暂无测评结果</Text>
        <Button mode="contained" onPress={() => navigation.navigate('ConstitutionQuestions')}>
          开始测评
        </Button>
      </View>
    )
  }

  const info = constitutionDescriptions[result.primaryType]
  const products = getProductsByConstitution(result.primaryType)

  // 计算所有体质得分用于显示
  const allScores = Object.entries(result.scores)
    .map(([type, score]) => ({
      type,
      name: constitutionNames[type as keyof typeof constitutionNames],
      score,
    }))
    .sort((a, b) => b.score - a.score)

  return (
    <ScrollView style={styles.container}>
      {/* 主体质卡片 */}
      <Card style={styles.primaryCard}>
        <Card.Content style={styles.primaryContent}>
          <Icon name="heart-pulse" size={48} color="#10B981" />
          <Text style={styles.primaryType}>{constitutionNames[result.primaryType]}</Text>
          <Text style={styles.primaryScore}>{result.scores[result.primaryType]}</Text>
          <Text style={styles.primaryDesc}>{info.description}</Text>
        </Card.Content>
      </Card>

      {/* 体质得分 */}
      <Card style={styles.card}>
        <Card.Title title="体质得分分布" />
        <Card.Content>
          {allScores.map((item) => (
            <View key={item.type} style={styles.scoreItem}>
              <Text style={styles.scoreName}>{item.name}</Text>
              <View style={styles.scoreBar}>
                <View style={[styles.scoreBarFill, { width: `${item.score}%` }]} />
              </View>
              <Text style={styles.scoreValue}>{item.score}</Text>
            </View>
          ))}
        </Card.Content>
      </Card>

      {/* 体质特征 */}
      <Card style={styles.card}>
        <Card.Title title="体质特征" />
        <Card.Content>
          <View style={styles.tagList}>
            {info.features.map((feature, index) => (
              <Chip key={index} style={styles.tag}>{feature}</Chip>
            ))}
          </View>
        </Card.Content>
      </Card>

      {/* 调养建议 */}
      <Card style={styles.card}>
        <Card.Title title="调养建议" />
        <Card.Content>
          {info.suggestions.map((suggestion, index) => (
            <View key={index} style={styles.suggestionItem}>
              <Icon name="check-circle" size={20} color="#10B981" />
              <Text style={styles.suggestionText}>{suggestion}</Text>
            </View>
          ))}
        </Card.Content>
      </Card>

      {/* 推荐产品 */}
      <Card style={styles.card}>
        <Card.Title title="推荐调养产品" />
        <Card.Content>
          {products.map((product) => (
            <View key={product.id} style={styles.productItem}>
              <Text style={styles.productName}>{product.name}</Text>
              <Text style={styles.productPrice}>¥{product.price}</Text>
            </View>
          ))}
        </Card.Content>
      </Card>

      {/* 操作按钮 */}
      <View style={styles.actions}>
        <Button
          mode="contained"
          onPress={() => navigation.navigate('ChatTab')}
          buttonColor="#10B981"
          style={styles.actionButton}
        >
          咨询AI助手
        </Button>
      </View>
    </ScrollView>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#F3F4F6', padding: 16 },
  emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  primaryCard: { borderRadius: 16, marginBottom: 16 },
  primaryContent: { alignItems: 'center', paddingVertical: 24 },
  primaryType: { fontSize: 28, fontWeight: 'bold', color: '#1F2937', marginTop: 12 },
  primaryScore: { fontSize: 18, color: '#10B981', marginTop: 4 },
  primaryDesc: { fontSize: 14, color: '#6B7280', marginTop: 12, textAlign: 'center', lineHeight: 22 },
  card: { borderRadius: 12, marginBottom: 16 },
  scoreItem: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
  scoreName: { width: 60, fontSize: 13 },
  scoreBar: { flex: 1, height: 8, backgroundColor: '#E5E7EB', borderRadius: 4, marginHorizontal: 8 },
  scoreBarFill: { height: '100%', backgroundColor: '#10B981', borderRadius: 4 },
  scoreValue: { width: 30, textAlign: 'right', fontSize: 13 },
  tagList: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
  tag: { backgroundColor: '#ECFDF5' },
  suggestionItem: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 12 },
  suggestionText: { flex: 1, marginLeft: 8, fontSize: 14, color: '#4B5563', lineHeight: 20 },
  productItem: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#E5E7EB' },
  productName: { fontSize: 14, color: '#1F2937' },
  productPrice: { fontSize: 14, color: '#EF4444', fontWeight: '600' },
  actions: { padding: 16 },
  actionButton: { borderRadius: 24 },
})

export default ConstitutionResultScreen

验收标准

  • 体质首页正常显示
  • 问卷60题可完整答题
  • 进度条显示正确
  • 提交后本地计算结果
  • 结果页显示体质类型和建议
  • 体质得分分布正确

预计耗时

40-50 分钟


下一步

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