HealthQuestionnaireWizard.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. <template>
  2. <view class="wizard-wrap">
  3. <!-- 内部步骤指示器(4 步:基础体征/疾病史/饮食习惯/确认) -->
  4. <view class="wizard-steps">
  5. <view
  6. v-for="(s, i) in wizardSteps"
  7. :key="i"
  8. class="wizard-step"
  9. :class="{ 'wizard-step-active': currentStep === i + 1, 'wizard-step-done': currentStep > i + 1 }"
  10. @click="goToStep(i + 1)"
  11. >
  12. <text class="wizard-step-num">{{ i + 1 }}</text>
  13. <text class="wizard-step-label">{{ s }}</text>
  14. </view>
  15. </view>
  16. <!-- 步骤内容 -->
  17. <view class="wizard-body">
  18. <!-- Step 1: 基础体征 -->
  19. <view v-if="currentStep === 1">
  20. <basic-vitals-step
  21. ref="stepRef"
  22. :value="currentMemberForm"
  23. @input="onVitalsInput"
  24. />
  25. </view>
  26. <!-- Step 2: 疾病史与用药 -->
  27. <view v-if="currentStep === 2">
  28. <medical-history-step
  29. ref="stepRef"
  30. :value="currentMemberForm"
  31. @input="onMedicalInput"
  32. />
  33. </view>
  34. <!-- Step 3: 生活方式与肠道健康 -->
  35. <view v-if="currentStep === 3">
  36. <lifestyle-step
  37. ref="stepRef"
  38. :value="currentMemberForm"
  39. :profile="currentMemberProfile"
  40. @input="onLifestyleInput"
  41. />
  42. </view>
  43. <!-- Step 4: 饮食习惯 -->
  44. <view v-if="currentStep === 4">
  45. <diet-habits-step
  46. ref="stepRef"
  47. :value="currentMemberDiet"
  48. @input="onDietInput"
  49. @skip-no-preference="onDietSkipNoPreference"
  50. />
  51. </view>
  52. <!-- Step 5: 确认 -->
  53. <view v-if="currentStep === 5">
  54. <confirm-step
  55. ref="stepRef"
  56. :health-status="currentMemberForm"
  57. :diet-preferences="currentMemberDiet"
  58. :submitting="submitting"
  59. @submit="handleSubmit"
  60. @back="prev"
  61. />
  62. </view>
  63. </view>
  64. <!-- 底部导航:上一步 / 下一步(最后一步为保存并生成) -->
  65. <view class="wizard-footer" v-if="currentStep < 5">
  66. <button class="btn-prev" :disabled="currentStep === 1" @click="prev">
  67. <text>上一步</text>
  68. </button>
  69. <button class="btn-next" :disabled="validating" @click="next">
  70. <text>{{ validating ? '校验中...' : '下一步' }}</text>
  71. </button>
  72. </view>
  73. </view>
  74. </template>
  75. <script>
  76. import BasicVitalsStep from './BasicVitalsStep.vue'
  77. import MedicalHistoryStep from './MedicalHistoryStep.vue'
  78. import LifestyleStep from './LifestyleStep.vue'
  79. import DietHabitsStep from './DietHabitsStep.vue'
  80. import ConfirmStep from './ConfirmStep.vue'
  81. export default {
  82. name: 'HealthQuestionnaireWizard',
  83. components: {
  84. BasicVitalsStep,
  85. MedicalHistoryStep,
  86. LifestyleStep,
  87. DietHabitsStep,
  88. ConfirmStep
  89. },
  90. props: {
  91. // 成员表单快照:memberId → { form, lipidCholesterol, selectedDiseases, medRows }
  92. memberFormMap: {
  93. type: Object,
  94. default: function() { return {} }
  95. },
  96. // 成员饮食习惯快照:memberId → dietPreferences 对象
  97. dietMap: {
  98. type: Object,
  99. default: function() { return {} }
  100. },
  101. // 成员基本信息:memberId → { gender, age }(问卷按 profile 动态调整问题)
  102. memberProfileMap: {
  103. type: Object,
  104. default: function() { return {} }
  105. },
  106. // 当前填写成员
  107. activeFormMemberId: {
  108. type: String,
  109. default: ''
  110. },
  111. // 提交中(由父组件 loading 驱动,失败后按钮可恢复)
  112. submitting: {
  113. type: Boolean,
  114. default: false
  115. }
  116. },
  117. data() {
  118. return {
  119. currentStep: 1,
  120. validating: false
  121. }
  122. },
  123. computed: {
  124. wizardSteps: function() {
  125. return ['基础体征', '疾病史', '生活方式', '饮食习惯', '确认']
  126. },
  127. // 当前成员的健康表单(来自父组件快照)
  128. currentMemberForm: function() {
  129. var map = this.memberFormMap || {}
  130. var key = this.activeFormMemberId || '__account'
  131. var snapshot = map[key] || {}
  132. return {
  133. heightCm: snapshot.form && snapshot.form.heightCm || '',
  134. weightKg: snapshot.form && snapshot.form.weightKg || '',
  135. bloodPressure: snapshot.form && snapshot.form.bloodPressure || '',
  136. bloodGlucose: snapshot.form && snapshot.form.bloodGlucose || '',
  137. notes: snapshot.form && snapshot.form.notes || '',
  138. lipidCholesterol: snapshot.lipidCholesterol || '',
  139. selectedDiseases: snapshot.selectedDiseases || [],
  140. medRows: snapshot.medRows || [],
  141. noMedication: !!snapshot.noMedication,
  142. mood: snapshot.lifestyle && snapshot.lifestyle.mood || '',
  143. sleepDuration: snapshot.lifestyle && snapshot.lifestyle.sleepDuration || '',
  144. bedtime: snapshot.lifestyle && snapshot.lifestyle.bedtime || '',
  145. bowelFrequency: snapshot.lifestyle && snapshot.lifestyle.bowelFrequency || '',
  146. bowelForm: snapshot.lifestyle && snapshot.lifestyle.bowelForm || '',
  147. antibioticHistory: snapshot.lifestyle && snapshot.lifestyle.antibioticHistory || '',
  148. allergySymptoms: snapshot.lifestyle && Array.isArray(snapshot.lifestyle.allergySymptoms) ? snapshot.lifestyle.allergySymptoms : [],
  149. menstrualCycle: snapshot.lifestyle && snapshot.lifestyle.menstrualCycle || '',
  150. specialPeriod: snapshot.lifestyle && snapshot.lifestyle.specialPeriod || '',
  151. growthDevelopment: snapshot.lifestyle && snapshot.lifestyle.growthDevelopment || '',
  152. pickyEating: snapshot.lifestyle && snapshot.lifestyle.pickyEating || '',
  153. osteoporosisRisk: snapshot.lifestyle && snapshot.lifestyle.osteoporosisRisk || '',
  154. fallRisk: snapshot.lifestyle && snapshot.lifestyle.fallRisk || '',
  155. screenTime: snapshot.lifestyle && snapshot.lifestyle.screenTime || '',
  156. teenMood: snapshot.lifestyle && snapshot.lifestyle.teenMood || ''
  157. }
  158. },
  159. // 当前成员的饮食习惯
  160. currentMemberDiet: function() {
  161. var map = this.dietMap || {}
  162. var key = this.activeFormMemberId || '__account'
  163. return map[key] || {}
  164. },
  165. // 当前成员的基本信息(性别/年龄),供 LifestyleStep 动态渲染
  166. currentMemberProfile: function() {
  167. var map = this.memberProfileMap || {}
  168. var key = this.activeFormMemberId || '__account'
  169. return map[key] || {}
  170. }
  171. },
  172. watch: {
  173. // 切换填写成员时回到第 1 步(各成员数据快照已保留,不影响已填内容)
  174. activeFormMemberId: function() {
  175. this.currentStep = 1
  176. }
  177. },
  178. methods: {
  179. // —— 步骤导航 ——
  180. goToStep: function(n) {
  181. if (n < 1 || n > 5) return
  182. if (n <= this.currentStep) {
  183. this.currentStep = n
  184. return
  185. }
  186. for (var s = this.currentStep; s < n; s++) {
  187. if (!this.validateStep(s)) return
  188. }
  189. this.currentStep = n
  190. },
  191. prev: function() {
  192. if (this.currentStep > 1) {
  193. this.currentStep -= 1
  194. }
  195. },
  196. next: function() {
  197. var pass = this.validateStep(this.currentStep)
  198. if (!pass) return
  199. if (this.currentStep < 5) {
  200. this.currentStep += 1
  201. }
  202. },
  203. validateStep: function(step) {
  204. var validatorMap = {
  205. 1: this.validateVitals,
  206. 2: this.validateMedical,
  207. 3: this.validateLifestyle,
  208. 4: this.validateDiet
  209. }
  210. var fn = validatorMap[step]
  211. if (fn) {
  212. var msg = fn.call(this)
  213. if (msg) {
  214. uni.showToast({ title: msg, icon: 'none' })
  215. return false
  216. }
  217. }
  218. return true
  219. },
  220. // Step1: 体征数值合法性
  221. validateVitals: function() {
  222. var form = this.currentMemberForm
  223. var height = String(form.heightCm || '').trim()
  224. var weight = String(form.weightKg || '').trim()
  225. var bp = String(form.bloodPressure || '').trim()
  226. var glucose = String(form.bloodGlucose || '').trim()
  227. if (height && !/^\d{2,3}(\.\d+)?$/.test(height)) {
  228. return '身高格式不正确'
  229. }
  230. if (weight && !/^\d{1,3}(\.\d+)?$/.test(weight)) {
  231. return '体重格式不正确'
  232. }
  233. if (bp && !/^\d{2,3}\/\d{2,3}$/.test(bp)) {
  234. return '血压格式应为 120/80'
  235. }
  236. if (glucose && !/^\d{1,2}(\.\d+)?$/.test(glucose)) {
  237. return '血糖格式不正确'
  238. }
  239. return ''
  240. },
  241. // Step2: 疾病史至少填写一项,或已标记"无长期用药"
  242. validateMedical: function() {
  243. var snapshot = this.currentMemberForm
  244. if (snapshot.noMedication) return ''
  245. var diseases = snapshot.selectedDiseases || []
  246. var meds = snapshot.medRows || []
  247. var hasMed = meds.some(function(m) { return m && m.name && String(m.name).trim() })
  248. if (diseases.length === 0 && !hasMed) {
  249. return '请至少填写一项疾病史或药物,如无请勾选"无长期用药"'
  250. }
  251. return ''
  252. },
  253. // Step3: 生活方式 — 心情/睡眠/排便/抗生素/过敏,所有字段可选,无强制校验
  254. validateLifestyle: function() {
  255. return ''
  256. },
  257. // Step4: 饮食习惯至少填写一项(忌口/偏好/必吃/菜系/烹饪/辣度/宗教饮食/目标/饮食结构任一)
  258. validateDiet: function() {
  259. var diet = this.currentMemberDiet || {}
  260. if (diet.noPreference) return ''
  261. var arrays = [diet.allergies, diet.absoluteAvoid, diet.mustEat, diet.flavorPref, diet.cuisinePref, diet.cookingMethods, diet.healthGoals]
  262. var hasAny = arrays.some(function(a) { return a && a.length > 0 })
  263. if (diet.vegetableFruit || diet.dairy || diet.sugaryDrinks) hasAny = true
  264. if (!hasAny && !(diet.spiceLevel && Number(diet.spiceLevel) > 0) && !(diet.religiousDiet && diet.religiousDiet !== 'none')) {
  265. return '请至少选择一项饮食习惯偏好或填写饮食结构'
  266. }
  267. return ''
  268. },
  269. onVitalsInput: function(payload) {
  270. this.$emit('update-vitals', payload)
  271. },
  272. onMedicalInput: function(payload) {
  273. this.$emit('update-medical', payload)
  274. },
  275. onLifestyleInput: function(payload) {
  276. this.$emit('update-lifestyle', payload)
  277. },
  278. onDietInput: function(payload) {
  279. this.$emit('update-diet', payload)
  280. },
  281. // 第4步「无特殊偏好」:跳过校验直接进入确认步
  282. onDietSkipNoPreference: function() {
  283. this.currentStep = 5
  284. },
  285. // —— 确认步骤提交:聚合所有成员数据后交给父组件统一保存+生成 ——
  286. handleSubmit: function() {
  287. var self = this
  288. this.$emit('submit', {
  289. memberFormMap: self.memberFormMap,
  290. dietMap: self.dietMap,
  291. activeFormMemberId: self.activeFormMemberId
  292. })
  293. }
  294. }
  295. }
  296. </script>
  297. <style scoped>
  298. .wizard-wrap {
  299. display: flex;
  300. flex-direction: column;
  301. min-height: 600rpx;
  302. }
  303. .wizard-steps {
  304. display: flex;
  305. justify-content: space-between;
  306. padding: 20rpx 10rpx 10rpx;
  307. background: #fff;
  308. }
  309. .wizard-step {
  310. display: flex;
  311. flex-direction: column;
  312. align-items: center;
  313. flex: 1;
  314. }
  315. .wizard-step-num {
  316. width: 44rpx;
  317. height: 44rpx;
  318. border-radius: 22rpx;
  319. background: #e0e0e0;
  320. color: #999;
  321. font-size: 24rpx;
  322. font-weight: bold;
  323. display: flex;
  324. align-items: center;
  325. justify-content: center;
  326. }
  327. .wizard-step-active .wizard-step-num {
  328. background: #F97316;
  329. color: #fff;
  330. }
  331. .wizard-step-done .wizard-step-num {
  332. background: #4CAF50;
  333. color: #fff;
  334. }
  335. .wizard-step-label {
  336. font-size: 20rpx;
  337. color: #999;
  338. white-space: nowrap;
  339. margin-top: 6rpx;
  340. }
  341. .wizard-step-active .wizard-step-label,
  342. .wizard-step-done .wizard-step-label {
  343. color: #333;
  344. font-weight: 500;
  345. }
  346. .wizard-body {
  347. flex: 1;
  348. padding: 10rpx 20rpx;
  349. }
  350. .wizard-footer {
  351. display: flex;
  352. padding: 20rpx 20rpx 30rpx;
  353. }
  354. .btn-prev,
  355. .btn-next {
  356. flex: 1;
  357. margin: 0 10rpx;
  358. border-radius: 999rpx;
  359. font-size: 30rpx;
  360. font-weight: 600;
  361. }
  362. .btn-prev {
  363. background: #F1F5F9;
  364. color: #334155;
  365. }
  366. .btn-next {
  367. background: linear-gradient(135deg, #F97316, #FB923C);
  368. color: #fff;
  369. }
  370. </style>