ComprehensiveSurvey.vue 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132
  1. <template>
  2. <view class="survey-wrap">
  3. <!-- 顶部进度条 -->
  4. <view class="progress-bar">
  5. <view class="progress-track">
  6. <view class="progress-fill" :style="'width:' + progressPercent + '%'"></view>
  7. </view>
  8. <view class="progress-labels">
  9. <text v-for="(m, i) in activeModules" :key="m"
  10. class="progress-label" :class="{ 'progress-label-active': currentStep > i }">{{ moduleTitles[m] }}</text>
  11. </view>
  12. </view>
  13. <!-- 步骤内容 -->
  14. <view class="content">
  15. <!-- ===== Step: 基本情况 basic ===== -->
  16. <view v-if="currentModule === 'basic'">
  17. <view class="step-head">
  18. <text class="step-icon">📏</text>
  19. <view class="step-head-text">
  20. <text class="step-title">基本情况</text>
  21. <text class="step-sub">身高、体重、血压、血糖、总胆固醇(快捷标签可一键填入参考值,再手改)</text>
  22. </view>
  23. </view>
  24. <view class="card">
  25. <view class="card-head"><text class="card-title">身高(cm)</text>
  26. <view class="quick-chips">
  27. <view class="quick-chip" :class="{ 'quick-chip-on': heightQuick === 'normal' }" @tap="setHeightQuick('normal')"><text>正常</text></view>
  28. <view class="quick-chip" :class="{ 'quick-chip-on': heightQuick === 'high' }" @tap="setHeightQuick('high')"><text>偏高</text></view>
  29. <view class="quick-chip" :class="{ 'quick-chip-on': heightQuick === 'low' }" @tap="setHeightQuick('low')"><text>偏低</text></view>
  30. </view>
  31. </view>
  32. <input class="num-input" type="digit" v-model="innerForm.heightCm" placeholder="如 170" />
  33. <text class="unit-hint">单位:厘米</text>
  34. </view>
  35. <view class="card">
  36. <view class="card-head"><text class="card-title">体重(kg)</text>
  37. <view class="quick-chips">
  38. <view class="quick-chip" :class="{ 'quick-chip-on': weightQuick === 'normal' }" @tap="setWeightQuick('normal')"><text>正常</text></view>
  39. <view class="quick-chip" :class="{ 'quick-chip-on': weightQuick === 'high' }" @tap="setWeightQuick('high')"><text>偏重</text></view>
  40. <view class="quick-chip" :class="{ 'quick-chip-on': weightQuick === 'low' }" @tap="setWeightQuick('low')"><text>偏轻</text></view>
  41. </view>
  42. </view>
  43. <input class="num-input" type="digit" v-model="innerForm.weightKg" placeholder="如 60" />
  44. <text class="unit-hint">单位:公斤</text>
  45. </view>
  46. <view class="card">
  47. <view class="card-head"><text class="card-title">血压</text>
  48. <view class="quick-chips">
  49. <view class="quick-chip" :class="{ 'quick-chip-on': bpQuick === 'normal' }" @tap="setBpQuick('normal')"><text>正常</text></view>
  50. <view class="quick-chip" :class="{ 'quick-chip-on': bpQuick === 'high' }" @tap="setBpQuick('high')"><text>偏高</text></view>
  51. <view class="quick-chip" :class="{ 'quick-chip-on': bpQuick === 'low' }" @tap="setBpQuick('low')"><text>偏低</text></view>
  52. </view>
  53. </view>
  54. <input class="num-input" type="text" v-model="innerForm.bloodPressure" placeholder="如 120/80" />
  55. <text class="unit-hint">格式:收缩压/舒张压(mmHg)</text>
  56. </view>
  57. <view class="card">
  58. <view class="card-head"><text class="card-title">空腹血糖(mmol/L)</text>
  59. <view class="quick-chips">
  60. <view class="quick-chip" :class="{ 'quick-chip-on': glucoseQuick === 'normal' }" @tap="setGlucoseQuick('normal')"><text>正常</text></view>
  61. <view class="quick-chip" :class="{ 'quick-chip-on': glucoseQuick === 'high' }" @tap="setGlucoseQuick('high')"><text>偏高</text></view>
  62. <view class="quick-chip" :class="{ 'quick-chip-on': glucoseQuick === 'low' }" @tap="setGlucoseQuick('low')"><text>偏低</text></view>
  63. </view>
  64. </view>
  65. <input class="num-input" type="digit" v-model="innerForm.bloodGlucose" placeholder="如 5.0" />
  66. </view>
  67. <view class="card">
  68. <view class="card-head"><text class="card-title">总胆固醇(mmol/L)</text>
  69. <view class="quick-chips">
  70. <view class="quick-chip" :class="{ 'quick-chip-on': lipidQuick === 'normal' }" @tap="setLipidQuick('normal')"><text>正常</text></view>
  71. <view class="quick-chip" :class="{ 'quick-chip-on': lipidQuick === 'high' }" @tap="setLipidQuick('high')"><text>偏高</text></view>
  72. </view>
  73. </view>
  74. <input class="num-input" type="digit" v-model="innerForm.lipidCholesterol" placeholder="如 4.5" />
  75. </view>
  76. <view class="card">
  77. <view class="card-head"><text class="card-title">其他补充(选填)</text></view>
  78. <textarea class="notes-area" v-model="innerForm.notes" placeholder="其他想补充的健康信息" :maxlength="500" />
  79. </view>
  80. </view>
  81. <!-- ===== Step: 当前状态 status ===== -->
  82. <view v-if="currentModule === 'status'">
  83. <view class="step-head">
  84. <text class="step-icon">🩺</text>
  85. <view class="step-head-text">
  86. <text class="step-title">当前状态</text>
  87. <text class="step-sub">特殊身体状态;按性别/年龄显示人群专属问题</text>
  88. </view>
  89. </view>
  90. <view class="card">
  91. <view class="card-head"><text class="card-title">特殊状态</text><text class="card-hint">单选</text></view>
  92. <view class="chip-row-wrap">
  93. <view v-for="opt in currentStatusOptions" :key="opt" class="chip-item-wrap"
  94. :class="{ 'chip-active': lifestyle.currentStatus === opt }"
  95. @tap="setLifestyle('currentStatus', opt)"><text>{{ opt }}</text></view>
  96. </view>
  97. </view>
  98. <!-- 女性专属 -->
  99. <view v-if="isFemale">
  100. <view class="card">
  101. <view class="card-head"><text class="card-title">月经周期</text><text class="card-hint">单选</text></view>
  102. <view class="chip-row-wrap">
  103. <view v-for="opt in menstrualOptions" :key="opt" class="chip-item-wrap"
  104. :class="{ 'chip-active': lifestyle.menstrualCycle === opt }"
  105. @tap="setLifestyle('menstrualCycle', opt)"><text>{{ opt }}</text></view>
  106. </view>
  107. </view>
  108. <view class="card">
  109. <view class="card-head"><text class="card-title">特殊时期</text><text class="card-hint">单选</text></view>
  110. <view class="chip-row-wrap">
  111. <view v-for="opt in specialPeriodOptions" :key="opt" class="chip-item-wrap"
  112. :class="{ 'chip-active': lifestyle.specialPeriod === opt }"
  113. @tap="setLifestyle('specialPeriod', opt)"><text>{{ opt }}</text></view>
  114. </view>
  115. </view>
  116. </view>
  117. <!-- 儿童专属 -->
  118. <view v-if="isChild">
  119. <view class="card">
  120. <view class="card-head"><text class="card-title">生长发育</text><text class="card-hint">单选</text></view>
  121. <view class="chip-row-wrap">
  122. <view v-for="opt in growthOptions" :key="opt" class="chip-item-wrap"
  123. :class="{ 'chip-active': lifestyle.growthDevelopment === opt }"
  124. @tap="setLifestyle('growthDevelopment', opt)"><text>{{ opt }}</text></view>
  125. </view>
  126. </view>
  127. <view class="card">
  128. <view class="card-head"><text class="card-title">挑食情况</text><text class="card-hint">单选</text></view>
  129. <view class="chip-row-wrap">
  130. <view v-for="opt in pickyEatingOptions" :key="opt" class="chip-item-wrap"
  131. :class="{ 'chip-active': lifestyle.pickyEating === opt }"
  132. @tap="setLifestyle('pickyEating', opt)"><text>{{ opt }}</text></view>
  133. </view>
  134. </view>
  135. </view>
  136. <!-- 老年专属 -->
  137. <view v-if="isElderly">
  138. <view class="card">
  139. <view class="card-head"><text class="card-title">骨质疏松风险</text><text class="card-hint">单选</text></view>
  140. <view class="chip-row-wrap">
  141. <view v-for="opt in osteoporosisOptions" :key="opt" class="chip-item-wrap"
  142. :class="{ 'chip-active': lifestyle.osteoporosisRisk === opt }"
  143. @tap="setLifestyle('osteoporosisRisk', opt)"><text>{{ opt }}</text></view>
  144. </view>
  145. </view>
  146. <view class="card">
  147. <view class="card-head"><text class="card-title">跌倒风险</text><text class="card-hint">单选</text></view>
  148. <view class="chip-row-wrap">
  149. <view v-for="opt in fallRiskOptions" :key="opt" class="chip-item-wrap"
  150. :class="{ 'chip-active': lifestyle.fallRisk === opt }"
  151. @tap="setLifestyle('fallRisk', opt)"><text>{{ opt }}</text></view>
  152. </view>
  153. </view>
  154. </view>
  155. <!-- 青少年专属 -->
  156. <view v-if="isTeenager">
  157. <view class="card">
  158. <view class="card-head"><text class="card-title">屏幕时间</text><text class="card-hint">单选</text></view>
  159. <view class="chip-row-wrap">
  160. <view v-for="opt in screenTimeOptions" :key="opt" class="chip-item-wrap"
  161. :class="{ 'chip-active': lifestyle.screenTime === opt }"
  162. @tap="setLifestyle('screenTime', opt)"><text>{{ opt }}</text></view>
  163. </view>
  164. </view>
  165. <view class="card">
  166. <view class="card-head"><text class="card-title">情绪状态</text><text class="card-hint">单选</text></view>
  167. <view class="chip-row-wrap">
  168. <view v-for="opt in teenMoodOptions" :key="opt" class="chip-item-wrap"
  169. :class="{ 'chip-active': lifestyle.teenMood === opt }"
  170. @tap="setLifestyle('teenMood', opt)"><text>{{ opt }}</text></view>
  171. </view>
  172. </view>
  173. </view>
  174. </view>
  175. <!-- ===== Step: 饮食偏好 diet ===== -->
  176. <view v-if="currentModule === 'diet'">
  177. <view class="step-head">
  178. <text class="step-icon">🍽️</text>
  179. <view class="step-head-text">
  180. <text class="step-title">饮食偏好</text>
  181. <text class="step-sub">忌口、口味、菜系、烹饪方式、饮食结构(过敏原在最后一步单独采集)</text>
  182. </view>
  183. </view>
  184. <view class="pref-none-row">
  185. <button class="pref-none-btn" :class="{ 'pref-none-btn-active': innerDiet.noPreference }"
  186. @tap="setNoPreference">{{ innerDiet.noPreference ? '已选:无特殊偏好' : '无特殊偏好,直接下一步' }}</button>
  187. </view>
  188. <view class="card">
  189. <view class="card-head"><text class="card-title">绝对忌口</text><text class="card-hint">多选</text></view>
  190. <view class="chip-row-wrap">
  191. <view v-for="item in presetAvoid" :key="item" class="chip-item-wrap"
  192. :class="{ 'chip-active': innerDiet.absoluteAvoid.indexOf(item) >= 0 }"
  193. @tap="toggleDietChip('absoluteAvoid', item)"><text>{{ item }}</text></view>
  194. </view>
  195. <view class="custom-row" v-if="customField === 'absoluteAvoid'">
  196. <input class="custom-input" placeholder="输入其他忌口" v-model="customInput" confirm-type="done" @confirm="addCustom" />
  197. <view class="custom-confirm" @tap="addCustom"><text>添加</text></view>
  198. </view>
  199. <view class="custom-add" v-else @tap="openCustom('absoluteAvoid')"><text>+ 自定义添加</text></view>
  200. </view>
  201. <view class="card">
  202. <view class="card-head"><text class="card-title">必吃食物</text><text class="card-hint">多选</text></view>
  203. <view class="chip-row-wrap">
  204. <view v-for="item in presetMustEat" :key="item" class="chip-item-wrap"
  205. :class="{ 'chip-active': innerDiet.mustEat.indexOf(item) >= 0 }"
  206. @tap="toggleDietChip('mustEat', item)"><text>{{ item }}</text></view>
  207. </view>
  208. </view>
  209. <view class="card">
  210. <view class="card-head"><text class="card-title">口味偏好</text><text class="card-hint">多选</text></view>
  211. <view class="chip-row-wrap">
  212. <view v-for="item in presetFlavor" :key="item" class="chip-item-wrap"
  213. :class="{ 'chip-active': innerDiet.flavorPref.indexOf(item) >= 0 }"
  214. @tap="toggleDietChip('flavorPref', item)"><text>{{ item }}</text></view>
  215. </view>
  216. </view>
  217. <view class="card">
  218. <view class="card-head"><text class="card-title">菜系偏好</text><text class="card-hint">多选</text></view>
  219. <view class="chip-row-wrap">
  220. <view v-for="item in presetCuisine" :key="item" class="chip-item-wrap"
  221. :class="{ 'chip-active': innerDiet.cuisinePref.indexOf(item) >= 0 }"
  222. @tap="toggleDietChip('cuisinePref', item)"><text>{{ item }}</text></view>
  223. </view>
  224. </view>
  225. <view class="card">
  226. <view class="card-head"><text class="card-title">烹饪方式</text><text class="card-hint">多选</text></view>
  227. <view class="chip-row-wrap">
  228. <view v-for="item in presetCooking" :key="item" class="chip-item-wrap"
  229. :class="{ 'chip-active': innerDiet.cookingMethods.indexOf(item) >= 0 }"
  230. @tap="toggleDietChip('cookingMethods', item)"><text>{{ item }}</text></view>
  231. </view>
  232. </view>
  233. <view class="card">
  234. <view class="card-head"><text class="card-title">健康目标</text><text class="card-hint">多选</text></view>
  235. <view class="chip-row-wrap">
  236. <view v-for="item in presetGoals" :key="item" class="chip-item-wrap"
  237. :class="{ 'chip-active': innerDiet.healthGoals.indexOf(item) >= 0 }"
  238. @tap="toggleDietChip('healthGoals', item)"><text>{{ item }}</text></view>
  239. </view>
  240. </view>
  241. <view class="card">
  242. <view class="card-head"><text class="card-title">辣度</text><text class="card-hint">{{ spiceLabels[innerDiet.spiceLevel || 0] }}</text></view>
  243. <view class="slider-row">
  244. <slider :value="innerDiet.spiceLevel || 0" min="0" max="5" step="1" activeColor="#FF8C42"
  245. @changing="onSpiceChanging" @change="onSpiceChange" />
  246. </view>
  247. </view>
  248. <view class="card">
  249. <view class="card-head"><text class="card-title">宗教饮食</text></view>
  250. <view class="chip-row-wrap">
  251. <view v-for="opt in religiousOptions" :key="opt.value" class="chip-item-wrap"
  252. :class="{ 'chip-active': innerDiet.religiousDiet === opt.value }"
  253. @tap="setDietValue('religiousDiet', opt.value)"><text>{{ opt.label }}</text></view>
  254. </view>
  255. </view>
  256. <view class="card">
  257. <view class="card-head"><text class="card-title">饮食结构现状</text><text class="card-hint">单选</text></view>
  258. <view class="struct-row"><text class="struct-label">每日蔬果量</text>
  259. <view class="chip-row-wrap">
  260. <view v-for="opt in vegFruitOptions" :key="opt" class="chip-item-wrap"
  261. :class="{ 'chip-active': innerDiet.vegetableFruit === opt }"
  262. @tap="setDietValue('vegetableFruit', opt)"><text>{{ opt }}</text></view>
  263. </view>
  264. </view>
  265. <view class="struct-row"><text class="struct-label">乳制品摄入</text>
  266. <view class="chip-row-wrap">
  267. <view v-for="opt in dairyOptions" :key="opt" class="chip-item-wrap"
  268. :class="{ 'chip-active': innerDiet.dairy === opt }"
  269. @tap="setDietValue('dairy', opt)"><text>{{ opt }}</text></view>
  270. </view>
  271. </view>
  272. <view class="struct-row"><text class="struct-label">含糖饮料频率</text>
  273. <view class="chip-row-wrap">
  274. <view v-for="opt in sugaryDrinkOptions" :key="opt" class="chip-item-wrap"
  275. :class="{ 'chip-active': innerDiet.sugaryDrinks === opt }"
  276. @tap="setDietValue('sugaryDrinks', opt)"><text>{{ opt }}</text></view>
  277. </view>
  278. </view>
  279. </view>
  280. </view>
  281. <!-- ===== Step: 疾病史 disease ===== -->
  282. <view v-if="currentModule === 'disease'">
  283. <view class="step-head">
  284. <text class="step-icon">📋</text>
  285. <view class="step-head-text">
  286. <text class="step-title">疾病史</text>
  287. <text class="step-sub">选择过往确诊疾病,可设确诊年月(★ 为重点关注项)</text>
  288. </view>
  289. </view>
  290. <view v-for="(group, gi) in diseaseGroups" :key="group.key" class="card">
  291. <view class="card-head" @tap="toggleGroup(group.key)">
  292. <text class="card-title">{{ group.title }}</text>
  293. <text class="group-arrow">{{ expandedGroups.indexOf(group.key) >= 0 ? '收起' : '展开' }}</text>
  294. </view>
  295. <view v-if="expandedGroups.indexOf(group.key) >= 0" class="chip-row-wrap">
  296. <view v-for="(item, ii) in group.items" :key="getDiseaseKey(gi, ii)" class="chip-item-wrap"
  297. :class="{ 'chip-active': selectedDiseaseNames.indexOf(item.name) >= 0 }"
  298. @tap="toggleDisease(item.name)">
  299. <text>{{ item.name }}</text>
  300. <text v-if="item.risk" class="risk-badge">★</text>
  301. </view>
  302. </view>
  303. </view>
  304. <view class="card">
  305. <view class="card-head"><text class="card-title">自定义疾病</text></view>
  306. <view class="custom-row">
  307. <input class="custom-input" type="text" v-model="diseaseInput" placeholder="输入其他疾病" />
  308. <view class="custom-confirm" @tap="addCustomDisease"><text>添加</text></view>
  309. </view>
  310. </view>
  311. <view class="card" v-if="innerForm.selectedDiseases.length > 0">
  312. <view class="card-head"><text class="card-title">已选疾病({{ innerForm.selectedDiseases.length }})</text></view>
  313. <view v-for="(item, si) in innerForm.selectedDiseases" :key="getSelKey(si)" class="sel-row">
  314. <text class="sel-name">{{ item.name }}</text>
  315. <picker mode="date" fields="month" :value="item.diagnosedAt || currentMonth" :end="monthEnd" @change="onDiagChange(si, $event)">
  316. <view class="sel-diag">{{ item.diagnosedAt ? item.diagnosedAt : '设确诊时间' }}</view>
  317. </picker>
  318. <text class="sel-del" @tap="removeSelected(si)">×</text>
  319. </view>
  320. </view>
  321. </view>
  322. <!-- ===== Step: 排便及运动 bowel ===== -->
  323. <view v-if="currentModule === 'bowel'">
  324. <view class="step-head">
  325. <text class="step-icon">🚽</text>
  326. <view class="step-head-text">
  327. <text class="step-title">排便及运动</text>
  328. <text class="step-sub">排便习惯 + 运动频率与每次时长</text>
  329. </view>
  330. </view>
  331. <view class="card">
  332. <view class="card-head"><text class="card-title">排便频率</text><text class="card-hint">单选</text></view>
  333. <view class="chip-row-wrap">
  334. <view v-for="opt in bowelFreqOptions" :key="opt" class="chip-item-wrap"
  335. :class="{ 'chip-active': lifestyle.bowelFrequency === opt }"
  336. @tap="setLifestyle('bowelFrequency', opt)"><text>{{ opt }}</text></view>
  337. </view>
  338. </view>
  339. <view class="card">
  340. <view class="card-head"><text class="card-title">粪便性状</text><text class="card-hint">单选</text></view>
  341. <view class="chip-row-wrap">
  342. <view v-for="opt in bowelFormOptions" :key="opt" class="chip-item-wrap"
  343. :class="{ 'chip-active': lifestyle.bowelForm === opt }"
  344. @tap="setLifestyle('bowelForm', opt)"><text>{{ opt }}</text></view>
  345. </view>
  346. </view>
  347. <view class="card">
  348. <view class="card-head"><text class="card-title">运动频率</text><text class="card-hint">单选</text></view>
  349. <view class="chip-row-wrap">
  350. <view v-for="opt in exerciseFreqOptions" :key="opt" class="chip-item-wrap"
  351. :class="{ 'chip-active': lifestyle.exerciseFrequency === opt }"
  352. @tap="setLifestyle('exerciseFrequency', opt)"><text>{{ opt }}</text></view>
  353. </view>
  354. </view>
  355. <view class="card">
  356. <view class="card-head"><text class="card-title">每次运动时长</text><text class="card-hint">单选</text></view>
  357. <view class="chip-row-wrap">
  358. <view v-for="opt in exerciseDurationOptions" :key="opt" class="chip-item-wrap"
  359. :class="{ 'chip-active': lifestyle.exerciseDuration === opt }"
  360. @tap="setLifestyle('exerciseDuration', opt)"><text>{{ opt }}</text></view>
  361. </view>
  362. </view>
  363. </view>
  364. <!-- ===== Step: 睡眠情况 sleep ===== -->
  365. <view v-if="currentModule === 'sleep'">
  366. <view class="step-head">
  367. <text class="step-icon">😴</text>
  368. <view class="step-head-text">
  369. <text class="step-title">睡眠情况</text>
  370. <text class="step-sub">睡眠时长与入睡时间</text>
  371. </view>
  372. </view>
  373. <view class="card">
  374. <view class="card-head"><text class="card-title">睡眠时长</text><text class="card-hint">单选</text></view>
  375. <view class="chip-row-wrap">
  376. <view v-for="opt in sleepDurationOptions" :key="opt" class="chip-item-wrap"
  377. :class="{ 'chip-active': lifestyle.sleepDuration === opt }"
  378. @tap="setLifestyle('sleepDuration', opt)"><text>{{ opt }}</text></view>
  379. </view>
  380. </view>
  381. <view class="card">
  382. <view class="card-head"><text class="card-title">入睡时间</text><text class="card-hint">单选</text></view>
  383. <view class="chip-row-wrap">
  384. <view v-for="opt in bedtimeOptions" :key="opt" class="chip-item-wrap"
  385. :class="{ 'chip-active': lifestyle.bedtime === opt }"
  386. @tap="setLifestyle('bedtime', opt)"><text>{{ opt }}</text></view>
  387. </view>
  388. </view>
  389. </view>
  390. <!-- ===== Step: 用药情况 med ===== -->
  391. <view v-if="currentModule === 'med'">
  392. <view class="step-head">
  393. <text class="step-icon">💊</text>
  394. <view class="step-head-text">
  395. <text class="step-title">用药情况</text>
  396. <text class="step-sub">当前在服药物;如无可勾选「无长期用药」</text>
  397. </view>
  398. </view>
  399. <view class="card">
  400. <view class="quick-chip" :class="{ 'quick-chip-on': innerForm.noMedication }" @tap="toggleNoMedication">
  401. <text>{{ innerForm.noMedication ? '✓ 已选' : '无长期用药' }}</text>
  402. </view>
  403. <text class="unit-hint" v-if="innerForm.noMedication">已标记无长期用药,可跳过药物填写</text>
  404. </view>
  405. <view v-if="!innerForm.noMedication" v-for="(row, idx) in innerForm.medRows" :key="getMedKey(idx)" class="card">
  406. <view class="card-head"><text class="card-title">用药 {{ idx + 1 }}</text>
  407. <text class="card-del" @tap="removeMed(idx)">删除</text>
  408. </view>
  409. <view class="card-row"><text class="card-label">药名</text>
  410. <input class="card-input" type="text" v-model="row.name" placeholder="药物品牌,如 立普妥" /></view>
  411. <view class="card-row"><text class="card-label">开始年月</text>
  412. <picker mode="date" fields="month" :value="row.startDate || currentMonth" :end="monthEnd" @change="onMedStartChange(idx, $event)">
  413. <view class="picker-val">{{ row.startDate ? row.startDate : '选择开始日期' }}</view>
  414. </picker>
  415. <text class="link-forget" @tap="onForgetClick(idx)">记不清</text>
  416. </view>
  417. <view class="card-row" v-if="row.durationText"><text class="card-label">已服时长</text>
  418. <text class="card-value">{{ row.durationText }}</text></view>
  419. <view class="card-row"><text class="card-label">服药时间</text>
  420. <input class="card-input" type="text" v-model="row.time" placeholder="如 早饭后一次" /></view>
  421. </view>
  422. <view class="add-row" v-if="!innerForm.noMedication" @tap="addMed"><text class="add-icon">+</text><text class="add-text">添加药物/治疗</text></view>
  423. </view>
  424. <!-- ===== Step: 过敏源 allergy ===== -->
  425. <view v-if="currentModule === 'allergy'">
  426. <view class="step-head">
  427. <text class="step-icon">⚠️</text>
  428. <view class="step-head-text">
  429. <text class="step-title">过敏源</text>
  430. <text class="step-sub">过敏原与过敏症状均可多选,可自定义添加</text>
  431. </view>
  432. </view>
  433. <view class="card">
  434. <view class="card-head"><text class="card-title">过敏原</text><text class="card-hint">多选</text></view>
  435. <view class="chip-row-wrap">
  436. <view v-for="item in presetAllergies" :key="item" class="chip-item-wrap"
  437. :class="{ 'chip-active': innerDiet.allergies.indexOf(item) >= 0 }"
  438. @tap="toggleDietChip('allergies', item)"><text>{{ item }}</text></view>
  439. </view>
  440. <view class="custom-row" v-if="customField === 'allergies'">
  441. <input class="custom-input" placeholder="输入其他过敏原" v-model="customInput" confirm-type="done" @confirm="addCustom" />
  442. <view class="custom-confirm" @tap="addCustom"><text>添加</text></view>
  443. </view>
  444. <view class="custom-add" v-else @tap="openCustom('allergies')"><text>+ 自定义添加</text></view>
  445. </view>
  446. <view class="card">
  447. <view class="card-head"><text class="card-title">过敏表现</text><text class="card-hint">多选</text></view>
  448. <view class="chip-row-wrap">
  449. <view v-for="opt in allergyOptions" :key="opt" class="chip-item-wrap"
  450. :class="{ 'chip-active': lifestyle.allergySymptoms.indexOf(opt) >= 0 }"
  451. @tap="toggleLifestyleArray('allergySymptoms', opt)"><text>{{ opt }}</text></view>
  452. </view>
  453. </view>
  454. </view>
  455. </view>
  456. <!-- 底部固定栏 -->
  457. <view class="footer-bar">
  458. <button class="btn-prev" v-if="currentStep > 1" @tap="prevStep">上一步</button>
  459. <button class="btn-next" v-if="currentStep < activeModules.length" @tap="nextStep">下一步</button>
  460. <button class="btn-done" v-else :disabled="submitting" @tap="onSubmit">
  461. {{ submitting ? '保存中...' : '完成' }}
  462. </button>
  463. </view>
  464. </view>
  465. </template>
  466. <script>
  467. // ===== 预设常量(与旧子组件 / preferences.vue 保持一致) =====
  468. var PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
  469. var PRESET_AVOID = ['牛肉', '羊肉', '猪肉', '内脏', '油腻', '辛辣', '生冷', '腌制']
  470. var PRESET_FLAVOR = ['酸', '甜', '咸', '鲜', '苦', '清淡', '浓郁']
  471. var PRESET_CUISINE = ['川', '粤', '鲁', '淮扬', '日料', '西餐', '东南亚', '韩餐']
  472. var PRESET_COOKING = ['蒸', '煮', '炒', '烤', '炖', '凉拌', '煎', '炸']
  473. var PRESET_MUSTEAT = ['蔬菜', '水果', '粗粮', '鱼类', '鸡蛋', '豆制品', '菌菇']
  474. var PRESET_GOALS = ['减脂', '增肌', '控糖', '肠道调理', '增强免疫']
  475. var VALID_MODULES = ['basic', 'status', 'diet', 'disease', 'bowel', 'sleep', 'med', 'allergy']
  476. // 8 组 53 项预置疾病(与旧 MedicalHistoryStep.vue 一致,risk=true 为重点关注项)
  477. var DISEASE_GROUPS = [
  478. { key: 'cardio', title: '心血管代谢', items: [
  479. { name: '高血压', risk: false },
  480. { name: '糖尿病', risk: false },
  481. { name: '血脂异常', risk: false },
  482. { name: '冠心病', risk: false },
  483. { name: '脑卒中', risk: false },
  484. { name: '心力衰竭', risk: false },
  485. { name: '心房颤动', risk: false },
  486. { name: '慢阻肺', risk: false },
  487. { name: '心脑血管疾病', risk: true },
  488. { name: 'II型糖尿病', risk: true },
  489. { name: '肥胖', risk: true },
  490. { name: '超重', risk: false },
  491. { name: '高尿酸血症', risk: false },
  492. { name: '骨质疏松', risk: false }
  493. ]},
  494. { key: 'digest', title: '消化系统', items: [
  495. { name: '幽门螺杆菌感染', risk: false },
  496. { name: '脂肪肝', risk: false },
  497. { name: '慢性胃炎', risk: false },
  498. { name: '胃食管反流', risk: false },
  499. { name: '肠易激综合征', risk: true },
  500. { name: '炎症性肠炎', risk: true },
  501. { name: '慢性便秘', risk: true },
  502. { name: '感染性腹泻', risk: true },
  503. { name: '胆结石', risk: false },
  504. { name: '炎症性肠病', risk: false },
  505. { name: '肝病', risk: true },
  506. { name: '胃病', risk: true },
  507. { name: '胆病', risk: true },
  508. { name: '肾病', risk: true },
  509. { name: '腹胀', risk: true }
  510. ]},
  511. { key: 'immune', title: '过敏免疫', items: [
  512. { name: '过敏性鼻炎', risk: true },
  513. { name: '哮喘', risk: false },
  514. { name: '湿疹', risk: false },
  515. { name: '食物过敏', risk: false },
  516. { name: '自体免疫病', risk: true }
  517. ]},
  518. { key: 'neuro', title: '精神神经', items: [
  519. { name: '抑郁症', risk: true },
  520. { name: '失眠', risk: true },
  521. { name: '焦虑障碍', risk: false },
  522. { name: '偏头痛', risk: false },
  523. { name: '自闭症', risk: true }
  524. ]},
  525. { key: 'resp', title: '呼吸系统', items: [
  526. { name: '肺部感染或疾病', risk: true },
  527. { name: '肺部疾病', risk: true }
  528. ]},
  529. { key: 'tumor', title: '肿瘤', items: [
  530. { name: '结直肠癌', risk: true },
  531. { name: '肺癌', risk: false },
  532. { name: '甲状腺癌', risk: false },
  533. { name: '肝癌', risk: false },
  534. { name: '胃癌', risk: false },
  535. { name: '乳腺癌', risk: false },
  536. { name: '食管癌', risk: false }
  537. ]},
  538. { key: 'child', title: '儿童青少年', items: [
  539. { name: '近视', risk: false },
  540. { name: '龋齿', risk: false },
  541. { name: '注意缺陷多动障碍', risk: false },
  542. { name: '甲状腺疾病', risk: true }
  543. ]},
  544. { key: 'other', title: '其他', items: [
  545. { name: '缺铁性贫血', risk: false },
  546. { name: '贫血', risk: false }
  547. ]}
  548. ]
  549. // 新采集的 lifestyle key(旧数据里可能没有,默认空值)
  550. var LIFESTYLE_KEYS = [
  551. 'currentStatus', 'menstrualCycle', 'specialPeriod', 'growthDevelopment', 'pickyEating',
  552. 'osteoporosisRisk', 'fallRisk', 'screenTime', 'teenMood',
  553. 'bowelFrequency', 'bowelForm', 'exerciseFrequency', 'exerciseDuration',
  554. 'sleepDuration', 'bedtime', 'allergySymptoms',
  555. 'mood', 'antibioticHistory'
  556. ]
  557. // lifestyle key → 所属调研模块(用于「主动清空」时判断是否允许下发 null)
  558. var LIFESTYLE_MODULE_MAP = {
  559. currentStatus: 'status', menstrualCycle: 'status', specialPeriod: 'status',
  560. growthDevelopment: 'status', pickyEating: 'status', osteoporosisRisk: 'status',
  561. fallRisk: 'status', screenTime: 'status', teenMood: 'status',
  562. bowelFrequency: 'bowel', bowelForm: 'bowel', exerciseFrequency: 'bowel', exerciseDuration: 'bowel',
  563. sleepDuration: 'sleep', bedtime: 'sleep',
  564. allergySymptoms: 'allergy',
  565. mood: '', antibioticHistory: ''
  566. }
  567. export default {
  568. name: 'ComprehensiveSurvey',
  569. props: {
  570. // 启用模块 key 列表(顺序决定步骤顺序;空/全非法 → 全量 8 模块)
  571. modules: { type: Array, default: function() { return [] } },
  572. // health_status 快照(结构化字段,lifestyle 为对象非 JSON 串)
  573. form: { type: Object, default: function() { return {} } },
  574. // diet_preferences 快照(13 key)
  575. diet: { type: Object, default: function() { return {} } },
  576. // 成员画像(gender/age 驱动条件渲染)
  577. profile: { type: Object, default: function() { return {} } },
  578. // 外部提交态
  579. submitting: { type: Boolean, default: false }
  580. },
  581. data: function() {
  582. return {
  583. currentStep: 1,
  584. innerForm: {},
  585. innerDiet: {},
  586. lifestyle: {},
  587. // 初始快照的疾病/用药数量(用于区分「主动清空」与「原本为空」)
  588. _initDiseaseCount: 0,
  589. _initMedCount: 0,
  590. currentMonth: '',
  591. monthEnd: '',
  592. expandedGroups: [],
  593. // 自定义输入
  594. customField: '',
  595. customInput: '',
  596. diseaseInput: '',
  597. // 快捷标签选中态(仅 UI 提示,值仍以输入框为准)
  598. heightQuick: '', weightQuick: '', bpQuick: '', glucoseQuick: '', lipidQuick: '',
  599. // 选项常量
  600. presetAllergies: PRESET_ALLERGIES,
  601. presetAvoid: PRESET_AVOID,
  602. presetFlavor: PRESET_FLAVOR,
  603. presetCuisine: PRESET_CUISINE,
  604. presetCooking: PRESET_COOKING,
  605. presetMustEat: PRESET_MUSTEAT,
  606. presetGoals: PRESET_GOALS,
  607. diseaseGroups: DISEASE_GROUPS,
  608. moduleTitles: {
  609. basic: '基本', status: '状态', diet: '饮食', disease: '疾病',
  610. bowel: '排便', sleep: '睡眠', med: '用药', allergy: '过敏'
  611. },
  612. currentStatusOptions: ['正常', '哺乳期', '备孕', '怀孕', '住院期', '恢复期', '其他'],
  613. menstrualOptions: ['规律(28-32天)', '不规律', '已停经', '尚未开始'],
  614. specialPeriodOptions: ['无', '备孕中', '怀孕', '哺乳期'],
  615. growthOptions: ['正常(在生长曲线内)', '偏慢', '偏快', '不清楚'],
  616. pickyEatingOptions: ['无挑食', '轻微挑食', '明显挑食', '严重挑食'],
  617. osteoporosisOptions: ['无风险', '有风险(曾骨折/骨密度低)', '不清楚'],
  618. fallRiskOptions: ['近1年无跌倒', '近1年跌倒1次', '近1年跌倒2次及以上'],
  619. screenTimeOptions: ['<1小时/天', '1-3小时/天', '3-5小时/天', '>5小时/天'],
  620. teenMoodOptions: ['开朗', '一般', '易怒', '低落'],
  621. bowelFreqOptions: ['每天1次(正常)', '隔天1次', '每周2-3次', '每周<2次'],
  622. bowelFormOptions: ['干硬颗粒', '条状干硬', '正常香蕉状', '软便', '水样'],
  623. exerciseFreqOptions: ['不运动', '每周1-2次', '每周3-4次', '每周5次以上'],
  624. exerciseDurationOptions: ['30分钟以内', '30-60分钟', '60分钟以上'],
  625. sleepDurationOptions: ['<6小时', '6-7小时', '7-8小时', '>8小时'],
  626. bedtimeOptions: ['22:00前', '22:00-24:00', '24:00后'],
  627. vegFruitOptions: ['<2份', '2-3份', '4份以上'],
  628. dairyOptions: ['每天', '每周3-5次', '很少', '不喝'],
  629. sugaryDrinkOptions: ['几乎不喝', '每周1-2次', '每周3-5次', '每天1次以上'],
  630. allergyOptions: ['无', '反复皮疹', '鼻痒/打喷嚏', '眼痒/流泪', '哮喘样症状', '食物过敏'],
  631. spiceLabels: ['不辣', '微辣', '中辣', '偏辣', '很辣', '特辣'],
  632. religiousOptions: [
  633. { value: 'none', label: '无限制' },
  634. { value: 'halal', label: '清真' },
  635. { value: 'vegetarian', label: '素食' },
  636. { value: 'vegan', label: '纯素' }
  637. ]
  638. }
  639. },
  640. computed: {
  641. // 过滤非法 key + 兜底全量 + 去重(保持顺序)
  642. activeModules: function() {
  643. var seen = {}
  644. var list = (this.modules || []).filter(function(m) {
  645. if (VALID_MODULES.indexOf(m) < 0 || seen[m]) return false
  646. seen[m] = true
  647. return true
  648. })
  649. if (list.length === 0) list = VALID_MODULES.slice()
  650. return list
  651. },
  652. currentModule: function() {
  653. return this.activeModules[this.currentStep - 1] || ''
  654. },
  655. progressPercent: function() {
  656. return Math.round((this.currentStep / this.activeModules.length) * 100)
  657. },
  658. // 已选疾病名列表(供模板内联 indexOf,避免 :class 带参方法调用)
  659. selectedDiseaseNames: function() {
  660. return (this.innerForm.selectedDiseases || []).map(function(d) { return d.name })
  661. },
  662. isFemale: function() {
  663. var g = this.profile && this.profile.gender
  664. return g === 'female' || g === '女'
  665. },
  666. isChild: function() {
  667. var age = this.profile && this.profile.age
  668. return age != null && Number(age) > 0 && Number(age) < 12
  669. },
  670. isTeenager: function() {
  671. var age = this.profile && this.profile.age
  672. return age != null && Number(age) >= 12 && Number(age) < 18
  673. },
  674. isElderly: function() {
  675. var age = this.profile && this.profile.age
  676. return age != null && Number(age) >= 60
  677. }
  678. },
  679. watch: {
  680. // 仅在 prop 对象身份变化时重建工作副本(父页换快照/切成员 tab);
  681. // 不用 deep:v-model 逐键输入会变更共享行对象,deep 会每次按键全量重建
  682. form: {
  683. handler: function(v) { this.initFromProps() },
  684. immediate: true
  685. },
  686. diet: {
  687. handler: function(v) { this.syncDiet(v) },
  688. immediate: true
  689. }
  690. },
  691. created: function() {
  692. var now = new Date()
  693. var y = now.getFullYear()
  694. var m = now.getMonth() + 1
  695. var mStr = m < 10 ? '0' + m : '' + m
  696. this.currentMonth = y + '-' + mStr
  697. var lastDay = new Date(y, now.getMonth(), 0).getDate()
  698. this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
  699. this.initFromProps()
  700. this.syncDiet(this.diet)
  701. // 默认展开前 3 组 + 已选疾病所在组
  702. this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
  703. var self = this
  704. ;(this.innerForm.selectedDiseases || []).forEach(function(d) {
  705. var gk = self.findGroupKey(d.name)
  706. if (gk && self.expandedGroups.indexOf(gk) < 0) self.expandedGroups.push(gk)
  707. })
  708. },
  709. methods: {
  710. // ===== 初始化 =====
  711. initFromProps: function() {
  712. var f = this.form || {}
  713. this.innerForm = {
  714. heightCm: f.heightCm != null ? f.heightCm : '',
  715. weightKg: f.weightKg != null ? f.weightKg : '',
  716. bloodPressure: f.bloodPressure != null ? f.bloodPressure : '',
  717. bloodGlucose: f.bloodGlucose != null ? f.bloodGlucose : '',
  718. notes: f.notes != null ? f.notes : '',
  719. lipidCholesterol: f.lipidCholesterol != null ? f.lipidCholesterol : '',
  720. selectedDiseases: Array.isArray(f.selectedDiseases) ? f.selectedDiseases.slice() : [],
  721. medRows: Array.isArray(f.medRows) ? f.medRows.slice() : [],
  722. noMedication: !!f.noMedication
  723. }
  724. var ls = f.lifestyle
  725. var obj = {}
  726. if (ls && typeof ls === 'object') {
  727. LIFESTYLE_KEYS.forEach(function(k) {
  728. obj[k] = ls[k] != null ? (ls[k] instanceof Array ? ls[k].slice() : ls[k]) : (k === 'allergySymptoms' ? [] : '')
  729. })
  730. } else {
  731. LIFESTYLE_KEYS.forEach(function(k) {
  732. obj[k] = k === 'allergySymptoms' ? [] : ''
  733. })
  734. }
  735. this.lifestyle = obj
  736. this._initDiseaseCount = (this.innerForm.selectedDiseases || []).length
  737. this._initMedCount = (this.innerForm.medRows || []).length
  738. // 快捷标签回显
  739. this.matchQuickLabels()
  740. },
  741. syncDiet: function(v) {
  742. var d = v || {}
  743. this.innerDiet = {
  744. allergies: Array.isArray(d.allergies) ? d.allergies.slice() : [],
  745. absoluteAvoid: Array.isArray(d.absoluteAvoid) ? d.absoluteAvoid.slice() : [],
  746. mustEat: Array.isArray(d.mustEat) ? d.mustEat.slice() : [],
  747. religiousDiet: d.religiousDiet || 'none',
  748. spiceLevel: d.spiceLevel != null ? d.spiceLevel : 0,
  749. flavorPref: Array.isArray(d.flavorPref) ? d.flavorPref.slice() : [],
  750. cuisinePref: Array.isArray(d.cuisinePref) ? d.cuisinePref.slice() : [],
  751. cookingMethods: Array.isArray(d.cookingMethods) ? d.cookingMethods.slice() : [],
  752. healthGoals: Array.isArray(d.healthGoals) ? d.healthGoals.slice() : [],
  753. noPreference: !!d.noPreference,
  754. vegetableFruit: d.vegetableFruit || '',
  755. dairy: d.dairy || '',
  756. sugaryDrinks: d.sugaryDrinks || ''
  757. }
  758. },
  759. matchQuickLabels: function() {
  760. this.heightQuick = this.matchQuick('heightCm', 'height')
  761. this.weightQuick = this.matchQuick('weightKg', 'weight')
  762. this.bpQuick = this.matchQuick('bloodPressure', 'bloodPressure')
  763. this.glucoseQuick = this.matchQuick('bloodGlucose', 'bloodGlucose')
  764. this.lipidQuick = this.matchQuick('lipidCholesterol', 'lipid')
  765. },
  766. matchQuick: function(field, quickKey) {
  767. var QUICK = {
  768. height: { normal: '170', high: '185', low: '155' },
  769. weight: { normal: '60', high: '80', low: '45' },
  770. bloodPressure: { normal: '120/80', high: '140/90', low: '90/60' },
  771. bloodGlucose: { normal: '5.0', high: '7.0', low: '3.5' },
  772. lipid: { normal: '4.5', high: '6.2' }
  773. }
  774. var map = QUICK[quickKey]
  775. var val = String(this.innerForm[field] || '')
  776. var keys = Object.keys(map)
  777. for (var i = 0; i < keys.length; i++) {
  778. if (String(map[keys[i]]) === val) return keys[i]
  779. }
  780. return ''
  781. },
  782. // ===== 步骤导航(前进步时同步快照到父页) =====
  783. prevStep: function() { if (this.currentStep > 1) this.currentStep-- },
  784. nextStep: function() {
  785. if (this.currentStep < this.activeModules.length) {
  786. this.$emit('update-form', this.formCopy())
  787. this.$emit('update-diet', this.dietCopy())
  788. this.currentStep++
  789. }
  790. },
  791. // ===== 提交 =====
  792. onSubmit: function() {
  793. if (this.submitting) return
  794. // 数值校验(与旧页一致)
  795. if (this.activeModules.indexOf('basic') >= 0) {
  796. var h = Number(this.innerForm.heightCm)
  797. var w = Number(this.innerForm.weightKg)
  798. var g = Number(this.innerForm.bloodGlucose)
  799. var bp = this.innerForm.bloodPressure
  800. if (this.innerForm.heightCm && (h < 30 || h > 250)) {
  801. uni.showToast({ title: '身高应在 30-250 之间', icon: 'none' }); return
  802. }
  803. if (this.innerForm.weightKg && (w < 3 || w > 300)) {
  804. uni.showToast({ title: '体重应在 3-300 之间', icon: 'none' }); return
  805. }
  806. if (this.innerForm.bloodGlucose && (g < 1 || g > 30)) {
  807. uni.showToast({ title: '血糖应在 1-30 之间', icon: 'none' }); return
  808. }
  809. if (bp && !/^\d{2,3}\/\d{2,3}$/.test(bp)) {
  810. uni.showToast({ title: '血压格式应为 120/80', icon: 'none' }); return
  811. }
  812. }
  813. // 空提交拦截
  814. var formCopy = this.formCopy()
  815. var dietCopy = this.dietCopy()
  816. var diff = this.buildLifestyleDiff()
  817. if (!this.hasAnyContent(formCopy, dietCopy, diff)) {
  818. uni.showToast({ title: '请至少填写一项', icon: 'none' }); return
  819. }
  820. this.$emit('submit', { form: formCopy, diet: dietCopy, lifestyleDiff: diff })
  821. },
  822. hasAnyContent: function(form, diet, diff) {
  823. var keys = ['heightCm', 'weightKg', 'bloodPressure', 'bloodGlucose', 'notes']
  824. for (var i = 0; i < keys.length; i++) {
  825. if (form[keys[i]] != null && form[keys[i]] !== '') return true
  826. }
  827. if (form.lipidCholesterol != null && form.lipidCholesterol !== '') return true
  828. if (form.selectedDiseases && form.selectedDiseases.length > 0) return true
  829. if (form.medRows && form.medRows.length > 0) return true
  830. if (form.noMedication) return true
  831. var dkeys = ['allergies', 'absoluteAvoid', 'mustEat', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals', 'vegetableFruit', 'dairy', 'sugaryDrinks']
  832. for (var j = 0; j < dkeys.length; j++) {
  833. var dv = diet[dkeys[j]]
  834. if (dv instanceof Array) { if (dv.length > 0) return true } else if (dv != null && dv !== '' && dv !== 'none') return true
  835. }
  836. if (diet.religiousDiet && diet.religiousDiet !== 'none') return true
  837. if (diet.spiceLevel && Number(diet.spiceLevel) > 0) return true
  838. if (diet.noPreference) return true
  839. return Object.keys(diff).length > 0
  840. },
  841. // 结构化 form 副本(不含 lifestyle;JSON 转换由父页负责)
  842. formCopy: function() {
  843. var diseases = this.innerForm.selectedDiseases || []
  844. var meds = this.innerForm.medRows || []
  845. return {
  846. heightCm: this.innerForm.heightCm,
  847. weightKg: this.innerForm.weightKg,
  848. bloodPressure: this.innerForm.bloodPressure,
  849. bloodGlucose: this.innerForm.bloodGlucose,
  850. notes: this.innerForm.notes,
  851. lipidCholesterol: this.innerForm.lipidCholesterol,
  852. selectedDiseases: diseases.slice(),
  853. medRows: meds.slice(),
  854. noMedication: !!this.innerForm.noMedication,
  855. // 原本有数据但被用户删空 → 置位,父页下发 '[]' 落库(否则旧值回滚)
  856. diseasesCleared: this._initDiseaseCount > 0 && diseases.length === 0,
  857. medsCleared: this._initMedCount > 0 && meds.length === 0
  858. }
  859. },
  860. dietCopy: function() {
  861. return {
  862. allergies: this.innerDiet.allergies || [],
  863. absoluteAvoid: this.innerDiet.absoluteAvoid || [],
  864. mustEat: this.innerDiet.mustEat || [],
  865. religiousDiet: this.innerDiet.religiousDiet || 'none',
  866. spiceLevel: this.innerDiet.spiceLevel || 0,
  867. flavorPref: this.innerDiet.flavorPref || [],
  868. cuisinePref: this.innerDiet.cuisinePref || [],
  869. cookingMethods: this.innerDiet.cookingMethods || [],
  870. healthGoals: this.innerDiet.healthGoals || [],
  871. noPreference: !!this.innerDiet.noPreference,
  872. vegetableFruit: this.innerDiet.vegetableFruit || '',
  873. dairy: this.innerDiet.dairy || '',
  874. sugaryDrinks: this.innerDiet.sugaryDrinks || ''
  875. }
  876. },
  877. // lifestyle diff:仅本次修改过的 key(未变化的 key 剔除,交由父页 merge 旧值)
  878. buildLifestyleDiff: function() {
  879. var diff = {}
  880. var orig = (this.form && this.form.lifestyle) || {}
  881. var self = this
  882. LIFESTYLE_KEYS.forEach(function(k) {
  883. var val = self.lifestyle[k]
  884. if (val == null) return
  885. var a = JSON.stringify(val)
  886. var b = JSON.stringify(orig[k] != null ? orig[k] : (k === 'allergySymptoms' ? [] : null))
  887. if (a === b) return
  888. if (val instanceof Array) { if (val.length > 0) diff[k] = val.slice() }
  889. else if (val !== '') diff[k] = val
  890. // 主动清空:值被删空且该 key 所属模块已启用 → 下发 null 让父页落库清除(未启用模块不清,保护旧数据)
  891. else if (val === '' && orig[k] && self.activeModules.indexOf(LIFESTYLE_MODULE_MAP[k]) >= 0) {
  892. diff[k] = null
  893. }
  894. })
  895. return diff
  896. },
  897. // ===== lifestyle 工具 =====
  898. setLifestyle: function(key, val) {
  899. this.$set(this.lifestyle, key, this.lifestyle[key] === val ? '' : val)
  900. },
  901. toggleLifestyleArray: function(key, val) {
  902. var arr = (this.lifestyle[key] || []).slice()
  903. var idx = arr.indexOf(val)
  904. if (idx >= 0) arr.splice(idx, 1); else arr.push(val)
  905. this.$set(this.lifestyle, key, arr)
  906. },
  907. // ===== diet 工具 =====
  908. setDietValue: function(key, val) {
  909. this.$set(this.innerDiet, key, val)
  910. },
  911. toggleDietChip: function(key, val) {
  912. var arr = (this.innerDiet[key] || []).slice()
  913. var idx = arr.indexOf(val)
  914. if (idx >= 0) arr.splice(idx, 1); else arr.push(val)
  915. this.$set(this.innerDiet, key, arr)
  916. this.$set(this.innerDiet, 'noPreference', false)
  917. },
  918. setNoPreference: function() {
  919. this.$set(this.innerDiet, 'noPreference', !this.innerDiet.noPreference)
  920. },
  921. onSpiceChanging: function(e) { this.$set(this.innerDiet, 'spiceLevel', e.detail.value) },
  922. onSpiceChange: function(e) {
  923. this.$set(this.innerDiet, 'spiceLevel', e.detail.value)
  924. this.$set(this.innerDiet, 'noPreference', false)
  925. },
  926. // ===== 自定义添加 =====
  927. openCustom: function(field) { this.customField = field; this.customInput = '' },
  928. addCustom: function() {
  929. var val = (this.customInput || '').trim()
  930. if (!val) { this.customField = ''; return }
  931. var arr = (this.innerDiet[this.customField] || []).slice()
  932. if (arr.indexOf(val) < 0) {
  933. arr.push(val)
  934. this.$set(this.innerDiet, this.customField, arr)
  935. }
  936. this.$set(this.innerDiet, 'noPreference', false)
  937. this.customInput = ''
  938. this.customField = ''
  939. },
  940. // ===== 体征快捷标签 =====
  941. setHeightQuick: function(key) {
  942. var v = { normal: '170', high: '185', low: '155' }[key]
  943. this.$set(this.innerForm, 'heightCm', v); this.heightQuick = key
  944. },
  945. setWeightQuick: function(key) {
  946. var v = { normal: '60', high: '80', low: '45' }[key]
  947. this.$set(this.innerForm, 'weightKg', v); this.weightQuick = key
  948. },
  949. setBpQuick: function(key) {
  950. var v = { normal: '120/80', high: '140/90', low: '90/60' }[key]
  951. this.$set(this.innerForm, 'bloodPressure', v); this.bpQuick = key
  952. },
  953. setGlucoseQuick: function(key) {
  954. var v = { normal: '5.0', high: '7.0', low: '3.5' }[key]
  955. this.$set(this.innerForm, 'bloodGlucose', v); this.glucoseQuick = key
  956. },
  957. setLipidQuick: function(key) {
  958. var v = { normal: '4.5', high: '6.2' }[key]
  959. this.$set(this.innerForm, 'lipidCholesterol', v); this.lipidQuick = key
  960. },
  961. // ===== v-for key 方法(微信小程序 :key 不支持表达式,须用方法调用) =====
  962. getDiseaseKey: function(gi, ii) { return 'g' + gi + '-' + ii },
  963. getSelKey: function(si) { return 's' + si },
  964. getMedKey: function(idx) { return 'm' + idx },
  965. // ===== 疾病 =====
  966. findGroupKey: function(name) {
  967. for (var i = 0; i < DISEASE_GROUPS.length; i++) {
  968. var items = DISEASE_GROUPS[i].items
  969. for (var j = 0; j < items.length; j++) {
  970. if (items[j].name === name) return DISEASE_GROUPS[i].key
  971. }
  972. }
  973. return ''
  974. },
  975. toggleGroup: function(key) {
  976. var idx = this.expandedGroups.indexOf(key)
  977. if (idx >= 0) this.expandedGroups.splice(idx, 1); else this.expandedGroups.push(key)
  978. },
  979. isDiseaseSelected: function(name) {
  980. return (this.innerForm.selectedDiseases || []).some(function(d) { return d.name === name })
  981. },
  982. toggleDisease: function(name) {
  983. var list = (this.innerForm.selectedDiseases || []).slice()
  984. var idx = -1
  985. for (var i = 0; i < list.length; i++) { if (list[i].name === name) { idx = i; break } }
  986. if (idx >= 0) list.splice(idx, 1); else list.push({ name: name, diagnosedAt: '', custom: false })
  987. this.$set(this.innerForm, 'selectedDiseases', list)
  988. },
  989. addCustomDisease: function() {
  990. var name = (this.diseaseInput || '').trim()
  991. if (!name) return
  992. var list = (this.innerForm.selectedDiseases || []).slice()
  993. if (!list.some(function(d) { return d.name === name })) {
  994. list.push({ name: name, diagnosedAt: '', custom: true })
  995. this.$set(this.innerForm, 'selectedDiseases', list)
  996. }
  997. this.diseaseInput = ''
  998. },
  999. removeSelected: function(idx) {
  1000. var list = (this.innerForm.selectedDiseases || []).slice()
  1001. list.splice(idx, 1)
  1002. this.$set(this.innerForm, 'selectedDiseases', list)
  1003. },
  1004. onDiagChange: function(idx, e) {
  1005. var list = (this.innerForm.selectedDiseases || []).slice()
  1006. list[idx] = Object.assign({}, list[idx], { diagnosedAt: e.detail.value })
  1007. this.$set(this.innerForm, 'selectedDiseases', list)
  1008. },
  1009. // ===== 用药 =====
  1010. toggleNoMedication: function() {
  1011. this.$set(this.innerForm, 'noMedication', !this.innerForm.noMedication)
  1012. },
  1013. addMed: function() {
  1014. var rows = (this.innerForm.medRows || []).slice()
  1015. rows.push({ name: '', startDate: '', durationText: '', time: '' })
  1016. this.$set(this.innerForm, 'medRows', rows)
  1017. },
  1018. removeMed: function(idx) {
  1019. var rows = (this.innerForm.medRows || []).slice()
  1020. rows.splice(idx, 1)
  1021. this.$set(this.innerForm, 'medRows', rows)
  1022. },
  1023. onMedStartChange: function(idx, e) {
  1024. var start = e.detail.value
  1025. var rows = (this.innerForm.medRows || []).slice()
  1026. rows[idx] = Object.assign({}, rows[idx], { startDate: start, durationText: this.calcDuration(start) })
  1027. this.$set(this.innerForm, 'medRows', rows)
  1028. },
  1029. onForgetClick: function(idx) {
  1030. var self = this
  1031. uni.showModal({
  1032. title: '记不清开始日期',
  1033. editable: true,
  1034. placeholderText: '如:约 3 个月',
  1035. success: function(res) {
  1036. if (res.confirm && res.content) {
  1037. var rows = (self.innerForm.medRows || []).slice()
  1038. rows[idx] = Object.assign({}, rows[idx], { durationText: res.content.trim() })
  1039. self.$set(self.innerForm, 'medRows', rows)
  1040. }
  1041. }
  1042. })
  1043. },
  1044. calcDuration: function(startDate) {
  1045. if (!startDate) return ''
  1046. var parts = startDate.split('-')
  1047. if (parts.length !== 2) return ''
  1048. var sy = parseInt(parts[0], 10)
  1049. var sm = parseInt(parts[1], 10)
  1050. var now = new Date()
  1051. var months = (now.getFullYear() - sy) * 12 + (now.getMonth() + 1 - sm)
  1052. if (months < 1) months = 1
  1053. if (months < 12) return '已服 ' + months + ' 个月'
  1054. return '已服 ' + Math.floor(months / 12) + ' 年'
  1055. }
  1056. }
  1057. }
  1058. </script>
  1059. <style scoped>
  1060. .survey-wrap { min-height: 100vh; background: linear-gradient(180deg, #FFF7ED, #FFF3E6); }
  1061. .progress-bar { padding: 24rpx 32rpx 8rpx; background: #FFF7ED; }
  1062. .progress-track { height: 8rpx; background: #FFE6D2; border-radius: 4rpx; overflow: hidden; }
  1063. .progress-fill { height: 100%; background: #FF8C42; border-radius: 4rpx; transition: width 0.3s; }
  1064. .progress-labels { display: flex; justify-content: space-between; margin-top: 12rpx; }
  1065. .progress-label { font-size: 20rpx; color: #B08968; }
  1066. .progress-label-active { color: #FF8C42; font-weight: 600; }
  1067. .content { padding: 24rpx 32rpx 220rpx; }
  1068. .step-head { display: flex; align-items: flex-start; margin-bottom: 24rpx; }
  1069. .step-icon { font-size: 48rpx; margin-right: 20rpx; }
  1070. .step-head-text { flex: 1; }
  1071. .step-title { display: block; font-size: 36rpx; font-weight: 700; color: #4A3728; }
  1072. .step-sub { display: block; font-size: 22rpx; color: #B08968; margin-top: 8rpx; line-height: 1.5; }
  1073. .card { background: #FFFFFF; border-radius: 24rpx; padding: 28rpx; margin-bottom: 24rpx; box-shadow: 0 4rpx 16rpx rgba(255, 140, 66, 0.08); }
  1074. .card-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; }
  1075. .card-title { display: block; font-size: 28rpx; font-weight: 600; color: #4A3728; margin-bottom: 16rpx; }
  1076. .card-head .card-title { margin-bottom: 0; }
  1077. .card-hint { font-size: 22rpx; color: #B08968; margin-bottom: 16rpx; }
  1078. .card-del { font-size: 24rpx; color: #E53E3E; padding: 8rpx 16rpx; }
  1079. .card-value { font-size: 26rpx; color: #4A3728; }
  1080. .group-arrow { font-size: 22rpx; color: #B08968; }
  1081. .card-row { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #F7EFE6; }
  1082. .card-row:last-child { border-bottom: none; }
  1083. .card-label { font-size: 26rpx; color: #6B5744; }
  1084. .card-input { flex: 1; text-align: right; font-size: 28rpx; color: #4A3728; }
  1085. .picker-val { font-size: 26rpx; color: #FF8C42; }
  1086. .num-input { display: block; width: 100%; background: #FFF3E6; border-radius: 16rpx; padding: 16rpx 20rpx; font-size: 28rpx; color: #4A3728; margin-top: 16rpx; }
  1087. .unit-hint { display: block; font-size: 20rpx; color: #C4A98E; margin-top: 8rpx; }
  1088. .notes-area { width: 100%; min-height: 120rpx; background: #FFF3E6; border-radius: 16rpx; padding: 16rpx 20rpx; font-size: 26rpx; color: #4A3728; margin-top: 16rpx; }
  1089. .quick-chips { display: flex; gap: 12rpx; }
  1090. .quick-chip { padding: 8rpx 20rpx; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 20rpx; border: 2rpx solid #FFE6D2; }
  1091. .quick-chip-on { background: #FF8C42; color: #FFFFFF; border-color: #FF8C42; }
  1092. .chip-row-wrap { display: flex; flex-wrap: wrap; gap: 16rpx; }
  1093. .chip-item-wrap { display: flex; align-items: center; gap: 4rpx; padding: 12rpx 28rpx; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 24rpx; border: 2rpx solid transparent; }
  1094. .chip-item-wrap.chip-active { background: #FF8C42; color: #FFFFFF; border-color: #FF8C42; font-weight: 600; }
  1095. .risk-badge { color: #E53E3E; font-size: 20rpx; }
  1096. .chip-active .risk-badge { color: #FFE6D2; }
  1097. .custom-row { display: flex; align-items: center; margin-top: 16rpx; gap: 16rpx; }
  1098. .custom-input { flex: 1; background: #FFF3E6; border-radius: 16rpx; padding: 16rpx 20rpx; font-size: 24rpx; color: #4A3728; }
  1099. .custom-confirm { padding: 12rpx 24rpx; background: #FF8C42; color: #FFF; border-radius: 16rpx; font-size: 24rpx; }
  1100. .custom-add { margin-top: 16rpx; font-size: 24rpx; color: #FF8C42; }
  1101. .struct-row { margin-bottom: 24rpx; }
  1102. .struct-row:last-child { margin-bottom: 0; }
  1103. .struct-label { display: block; font-size: 24rpx; color: #6B5744; margin-bottom: 12rpx; }
  1104. .slider-row { display: flex; align-items: center; gap: 24rpx; }
  1105. .sel-row { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #F7EFE6; }
  1106. .sel-row:last-child { border-bottom: none; }
  1107. .sel-name { flex: 1; font-size: 26rpx; color: #4A3728; }
  1108. .sel-diag { font-size: 24rpx; color: #FF8C42; padding: 8rpx 20rpx; background: #FFF3E6; border-radius: 999rpx; margin: 0 16rpx; }
  1109. .sel-del { font-size: 32rpx; color: #B08968; padding: 0 12rpx; }
  1110. .link-forget { font-size: 24rpx; color: #FF8C42; padding: 8rpx 16rpx; }
  1111. .pref-none-row { margin-bottom: 24rpx; }
  1112. .pref-none-btn { width: 100%; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 26rpx; padding: 16rpx; border: 2rpx solid #FFD4B8; }
  1113. .pref-none-btn-active { background: #FF8C42; color: #FFF; border-color: #FF8C42; }
  1114. .add-row { display: flex; align-items: center; justify-content: center; padding: 24rpx; border: 2rpx dashed #FFD4B8; border-radius: 24rpx; color: #FF8C42; font-size: 26rpx; background: #FFFFFF; }
  1115. .add-icon { margin-right: 8rpx; }
  1116. .footer-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); background: #FFFFFF; box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06); display: flex; gap: 20rpx; }
  1117. .btn-prev, .btn-next, .btn-done { flex: 1; border-radius: 999rpx; font-size: 28rpx; font-weight: 600; padding: 20rpx 0; background: #FFF3E6; color: #6B5744; line-height: 1.6; }
  1118. .btn-next, .btn-done { background: #FF8C42; color: #FFFFFF; }
  1119. .btn-done[disabled] { opacity: 0.6; }
  1120. </style>