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.
15 KiB
15 KiB
05-体质辨识页面
目标
实现中医体质辨识问卷页面,包括问卷填写和结果展示。
UI 设计参考
参考设计稿:
files/ui/体质页.png、files/ui/体质检测.png、files/ui/体质分析.png
体质首页布局
| 区域 | 设计要点 |
|---|---|
| 顶部卡片 | 绿色渐变背景,"中医体质自测" 标题 + 介绍文案 |
| 测试说明 | 白色卡片,3步骤(绿色序号圆圈 + 说明文字) |
| 按钮 | 绿色全宽圆角按钮 "开始测试",圆角 24px |
问卷页面布局
| 区域 | 设计要点 |
|---|---|
| 导航栏 | 返回箭头 + "中医体质辨识自测" + 进度 "1/65" |
| 进度条 | 绿色细条 #10B981,高度 4px |
| 问题标签 | 绿色背景 #DCFCE7,文字 #10B981,"问题N" |
| 问题文字 | 字号 18px,颜色 #1F2937 |
| 选项按钮 | 白色背景,边框 #E5E7EB,圆角 12px,选中高亮绿色 |
| 底部提示 | 浅绿色背景 #ECFDF5,带 info 图标 |
结果页面布局
| 区域 | 设计要点 |
|---|---|
| 顶部 | 绿色渐变背景 + 人体轮廓图 + 分享按钮 |
| 体质名称 | 大字体 32px,白色 |
| 分数徽章 | 绿色背景圆角标签 "85分" |
| 雷达图 | 白色卡片,九种体质得分可视化,颜色 #10B981 |
| 体质特征 | 图标 + 描述文字 |
| 调理建议 | 2×2 网格布局 |
调理建议卡片配色
| 类型 | 图标背景 | 图标颜色 |
|---|---|---|
| 起居 | #EDE9FE |
#8B5CF6 |
| 饮食 | #CCFBF1 |
#14B8A6 |
| 运动 | #EDE9FE |
#8B5CF6 |
| 情志 | #FCE7F3 |
#EC4899 |
前置要求
- 健康调查页面完成
- 后端体质接口可用
实施步骤
步骤 1:创建体质 API
创建 src/api/constitution.ts:
import request from './request'
import type { Question, ConstitutionResult } from '@/types'
export const getQuestions = (): Promise<Question[]> => {
return request.get('/constitution/questions')
}
export const submitAssessment = (answers: { question_id: number; score: number }[]): Promise<ConstitutionResult> => {
return request.post('/constitution/submit', { answers })
}
export const getLatestResult = (): Promise<ConstitutionResult> => {
return request.get('/constitution/result')
}
export const getAssessmentHistory = () => {
return request.get('/constitution/history')
}
步骤 2:创建体质测评主页面
创建 src/views/constitution/Index.vue:
<template>
<div class="constitution-page">
<div class="page-header">
<h2>中医体质辨识</h2>
<p>基于《中医体质分类与判定》标准,共 {{ questions.length }} 道题目</p>
</div>
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="!started" class="start-container">
<el-card class="intro-card">
<h3>什么是中医体质辨识?</h3>
<p>
中医体质辨识是根据中医理论,通过对您日常生活习惯、身体感受等方面的调查,
判断您属于九种体质中的哪一种或哪几种,从而为您提供个性化的健康调养建议。
</p>
<h4>九种体质类型</h4>
<div class="constitution-types">
<el-tag v-for="item in constitutionTypes" :key="item.type" size="large">
{{ item.name }}
</el-tag>
</div>
<div class="start-action">
<el-button type="primary" size="large" @click="startAssessment">
开始测评
</el-button>
</div>
</el-card>
</div>
<div v-else class="questionnaire-container">
<el-progress
:percentage="progress"
:format="() => `${currentIndex + 1} / ${questions.length}`"
style="margin-bottom: 20px"
/>
<el-card class="question-card">
<div class="question-type">
{{ constitutionNameMap[currentQuestion.constitution_type] }}
</div>
<div class="question-text">
{{ currentIndex + 1 }}. {{ currentQuestion.question_text }}
</div>
<div class="options">
<el-button
v-for="(option, index) in options"
:key="index"
:type="answers[currentQuestion.id] === index + 1 ? 'primary' : 'default'"
size="large"
@click="selectOption(index + 1)"
>
{{ option }}
</el-button>
</div>
</el-card>
<div class="nav-actions">
<el-button
:disabled="currentIndex === 0"
size="large"
@click="prevQuestion"
>
上一题
</el-button>
<el-button
v-if="currentIndex < questions.length - 1"
type="primary"
size="large"
:disabled="!answers[currentQuestion.id]"
@click="nextQuestion"
>
下一题
</el-button>
<el-button
v-else
type="success"
size="large"
:loading="submitting"
:disabled="!isAllAnswered"
@click="submitAssessmentHandler"
>
提交测评
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { Question } from '@/types'
import { getQuestions, submitAssessment } from '@/api/constitution'
const router = useRouter()
const loading = ref(true)
const started = ref(false)
const submitting = ref(false)
const questions = ref<Question[]>([])
const currentIndex = ref(0)
const answers = ref<Record<number, number>>({})
const options = ['没有', '很少', '有时', '经常', '总是']
const constitutionTypes = [
{ type: 'pinghe', name: '平和质' },
{ type: 'qixu', name: '气虚质' },
{ type: 'yangxu', name: '阳虚质' },
{ type: 'yinxu', name: '阴虚质' },
{ type: 'tanshi', name: '痰湿质' },
{ type: 'shire', name: '湿热质' },
{ type: 'xueyu', name: '血瘀质' },
{ type: 'qiyu', name: '气郁质' },
{ type: 'tebing', name: '特禀质' },
]
const constitutionNameMap: Record<string, string> = {
pinghe: '平和质',
qixu: '气虚质',
yangxu: '阳虚质',
yinxu: '阴虚质',
tanshi: '痰湿质',
shire: '湿热质',
xueyu: '血瘀质',
qiyu: '气郁质',
tebing: '特禀质',
}
const currentQuestion = computed(() => questions.value[currentIndex.value])
const progress = computed(() => {
return Math.round(((currentIndex.value + 1) / questions.value.length) * 100)
})
const isAllAnswered = computed(() => {
return questions.value.every((q) => answers.value[q.id])
})
onMounted(async () => {
try {
questions.value = await getQuestions()
} catch (error) {
ElMessage.error('获取问卷失败')
} finally {
loading.value = false
}
})
const startAssessment = () => {
started.value = true
}
const selectOption = (score: number) => {
answers.value[currentQuestion.value.id] = score
}
const prevQuestion = () => {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
const nextQuestion = () => {
if (currentIndex.value < questions.value.length - 1) {
currentIndex.value++
}
}
const submitAssessmentHandler = async () => {
submitting.value = true
try {
const answerList = Object.entries(answers.value).map(([questionId, score]) => ({
question_id: parseInt(questionId),
score,
}))
await submitAssessment(answerList)
ElMessage.success('测评完成')
router.push('/constitution/result')
} catch (error) {
// 错误已处理
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.constitution-page {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin-bottom: 8px;
}
.page-header p {
color: #666;
}
.loading-container {
padding: 40px;
background: #fff;
border-radius: 8px;
}
.intro-card {
padding: 20px;
}
.intro-card h3 {
margin-bottom: 16px;
}
.intro-card p {
color: #666;
line-height: 1.8;
}
.intro-card h4 {
margin: 24px 0 12px;
}
.constitution-types {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.start-action {
margin-top: 32px;
text-align: center;
}
.question-card {
padding: 30px;
}
.question-type {
display: inline-block;
padding: 4px 12px;
background: #ecf5ff;
color: #409eff;
border-radius: 4px;
font-size: 13px;
margin-bottom: 16px;
}
.question-text {
font-size: 18px;
line-height: 1.6;
margin-bottom: 24px;
}
.options {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.options .el-button {
min-width: 100px;
}
.nav-actions {
margin-top: 24px;
display: flex;
justify-content: space-between;
}
</style>
步骤 3:创建结果展示页面
创建 src/views/constitution/Result.vue:
<template>
<div class="result-page">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<template v-else-if="result">
<!-- 主要体质 -->
<el-card class="primary-card">
<div class="result-header">
<h2>您的体质类型</h2>
<el-tag size="large" type="success">{{ result.primary_constitution.name }}</el-tag>
</div>
<p class="description">{{ result.primary_constitution.description }}</p>
</el-card>
<!-- 体质雷达图 -->
<el-card class="chart-card">
<h3>体质得分分布</h3>
<div ref="chartRef" class="chart-container"></div>
</el-card>
<!-- 次要体质 -->
<el-card v-if="result.secondary_constitutions?.length" class="secondary-card">
<h3>次要体质倾向</h3>
<div class="secondary-list">
<div
v-for="item in result.secondary_constitutions"
:key="item.type"
class="secondary-item"
>
<span class="name">{{ item.name }}</span>
<el-progress
:percentage="item.score"
:stroke-width="10"
:format="() => item.score.toFixed(0) + '分'"
/>
</div>
</div>
</el-card>
<!-- 调养建议 -->
<el-card class="recommendations-card">
<h3>调养建议</h3>
<el-tabs>
<el-tab-pane
v-for="(recs, type) in result.recommendations"
:key="type"
:label="constitutionNameMap[type]"
>
<div class="rec-list">
<div class="rec-item">
<el-icon><Bowl /></el-icon>
<div>
<h4>饮食调养</h4>
<p>{{ recs.diet }}</p>
</div>
</div>
<div class="rec-item">
<el-icon><House /></el-icon>
<div>
<h4>起居调养</h4>
<p>{{ recs.lifestyle }}</p>
</div>
</div>
<div class="rec-item">
<el-icon><Bicycle /></el-icon>
<div>
<h4>运动调养</h4>
<p>{{ recs.exercise }}</p>
</div>
</div>
<div class="rec-item">
<el-icon><Sunny /></el-icon>
<div>
<h4>情志调养</h4>
<p>{{ recs.emotion }}</p>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<div class="actions">
<el-button type="primary" size="large" @click="$router.push('/chat')">
开始 AI 问诊
</el-button>
<el-button size="large" @click="$router.push('/constitution')">
重新测评
</el-button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Bowl, House, Bicycle, Sunny } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import type { ConstitutionResult } from '@/types'
import { getLatestResult } from '@/api/constitution'
const loading = ref(true)
const result = ref<ConstitutionResult | null>(null)
const chartRef = ref<HTMLElement>()
const constitutionNameMap: Record<string, string> = {
pinghe: '平和质',
qixu: '气虚质',
yangxu: '阳虚质',
yinxu: '阴虚质',
tanshi: '痰湿质',
shire: '湿热质',
xueyu: '血瘀质',
qiyu: '气郁质',
tebing: '特禀质',
}
onMounted(async () => {
try {
result.value = await getLatestResult()
await nextTick()
initChart()
} catch (error) {
ElMessage.error('获取结果失败')
} finally {
loading.value = false
}
})
const initChart = () => {
if (!chartRef.value || !result.value) return
const chart = echarts.init(chartRef.value)
const data = result.value.all_scores.map((item) => ({
name: item.name,
value: item.score,
}))
chart.setOption({
radar: {
indicator: data.map((d) => ({ name: d.name, max: 100 })),
radius: '65%',
},
series: [
{
type: 'radar',
data: [
{
value: data.map((d) => d.value),
name: '体质得分',
areaStyle: {
color: 'rgba(64, 158, 255, 0.3)',
},
lineStyle: {
color: '#409eff',
},
itemStyle: {
color: '#409eff',
},
},
],
},
],
})
window.addEventListener('resize', () => chart.resize())
}
</script>
<style scoped>
.result-page {
max-width: 800px;
margin: 0 auto;
}
.loading-container {
padding: 40px;
background: #fff;
border-radius: 8px;
}
.el-card {
margin-bottom: 20px;
}
.primary-card {
text-align: center;
padding: 20px;
}
.result-header {
margin-bottom: 16px;
}
.result-header h2 {
margin-bottom: 12px;
}
.description {
color: #666;
font-size: 15px;
}
.chart-card h3 {
margin-bottom: 16px;
}
.chart-container {
height: 350px;
}
.secondary-card h3 {
margin-bottom: 16px;
}
.secondary-item {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.secondary-item .name {
width: 80px;
flex-shrink: 0;
}
.secondary-item .el-progress {
flex: 1;
}
.recommendations-card h3 {
margin-bottom: 16px;
}
.rec-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.rec-item {
display: flex;
gap: 12px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}
.rec-item .el-icon {
font-size: 24px;
color: #409eff;
flex-shrink: 0;
}
.rec-item h4 {
margin-bottom: 8px;
font-size: 14px;
}
.rec-item p {
color: #666;
font-size: 13px;
line-height: 1.6;
}
.actions {
text-align: center;
padding: 20px 0;
}
.actions .el-button + .el-button {
margin-left: 16px;
}
</style>
需要创建的文件清单
| 文件路径 | 说明 |
|---|---|
src/api/constitution.ts |
体质 API |
src/views/constitution/Index.vue |
测评主页面 |
src/views/constitution/Result.vue |
结果展示页面 |
验收标准
- 问卷题目正确加载
- 答题进度显示正常
- 提交后跳转结果页
- 雷达图正确显示
- 调养建议展示完整
预计耗时
35-40 分钟
下一步
完成后进入 03-Web前端开发/06-AI对话页面.md