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
11 KiB
05-体质辨识页面
目标
实现 APP 端中医体质辨识问卷和结果展示功能。
UI 设计参考
参考设计稿:
files/ui/体质页.png、files/ui/体质检测.png、files/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 |
实现要点
- 问卷 UI:使用卡片+按钮组形式展示题目和选项
- 进度条:使用
ProgressBar显示答题进度 - 雷达图:使用
react-native-gifted-charts或victory-native - 结果展示:使用 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