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.
 
 
 
 
 
 

15 KiB

05-体质辨识页面

目标

实现中医体质辨识问卷页面,包括问卷填写和结果展示。


UI 设计参考

参考设计稿:files/ui/体质页.pngfiles/ui/体质检测.pngfiles/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