HealthQuestionnaireWizard.vue 12 KB

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