HealthQuestionnaireWizard.vue 9.6 KB

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