ComprehensiveSurvey.vue 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160
  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="stool-tip">参照布里斯托大便分类法,选择最接近您多数情况的一项</view>
  342. <view class="stool-grid">
  343. <view v-for="opt in bowelFormOptions" :key="opt.value" class="stool-item"
  344. :class="{ 'stool-active': lifestyle.bowelForm === opt.value }"
  345. @tap="setLifestyle('bowelForm', opt.value)">
  346. <image class="stool-img" :src="opt.img" mode="aspectFit" />
  347. <text class="stool-name">{{ opt.label }}</text>
  348. <text class="stool-desc">{{ opt.desc }}</text>
  349. </view>
  350. </view>
  351. </view>
  352. <view class="card">
  353. <view class="card-head"><text class="card-title">运动频率</text><text class="card-hint">单选</text></view>
  354. <view class="chip-row-wrap">
  355. <view v-for="opt in exerciseFreqOptions" :key="opt" class="chip-item-wrap"
  356. :class="{ 'chip-active': lifestyle.exerciseFrequency === opt }"
  357. @tap="setLifestyle('exerciseFrequency', opt)"><text>{{ opt }}</text></view>
  358. </view>
  359. </view>
  360. <view class="card">
  361. <view class="card-head"><text class="card-title">每次运动时长</text><text class="card-hint">单选</text></view>
  362. <view class="chip-row-wrap">
  363. <view v-for="opt in exerciseDurationOptions" :key="opt" class="chip-item-wrap"
  364. :class="{ 'chip-active': lifestyle.exerciseDuration === opt }"
  365. @tap="setLifestyle('exerciseDuration', opt)"><text>{{ opt }}</text></view>
  366. </view>
  367. </view>
  368. </view>
  369. <!-- ===== Step: 睡眠情况 sleep ===== -->
  370. <view v-if="currentModule === 'sleep'">
  371. <view class="step-head">
  372. <text class="step-icon">😴</text>
  373. <view class="step-head-text">
  374. <text class="step-title">睡眠情况</text>
  375. <text class="step-sub">睡眠时长与入睡时间</text>
  376. </view>
  377. </view>
  378. <view class="card">
  379. <view class="card-head"><text class="card-title">睡眠时长</text><text class="card-hint">单选</text></view>
  380. <view class="chip-row-wrap">
  381. <view v-for="opt in sleepDurationOptions" :key="opt" class="chip-item-wrap"
  382. :class="{ 'chip-active': lifestyle.sleepDuration === opt }"
  383. @tap="setLifestyle('sleepDuration', opt)"><text>{{ opt }}</text></view>
  384. </view>
  385. </view>
  386. <view class="card">
  387. <view class="card-head"><text class="card-title">入睡时间</text><text class="card-hint">单选</text></view>
  388. <view class="chip-row-wrap">
  389. <view v-for="opt in bedtimeOptions" :key="opt" class="chip-item-wrap"
  390. :class="{ 'chip-active': lifestyle.bedtime === opt }"
  391. @tap="setLifestyle('bedtime', opt)"><text>{{ opt }}</text></view>
  392. </view>
  393. </view>
  394. </view>
  395. <!-- ===== Step: 用药情况 med ===== -->
  396. <view v-if="currentModule === 'med'">
  397. <view class="step-head">
  398. <text class="step-icon">💊</text>
  399. <view class="step-head-text">
  400. <text class="step-title">用药情况</text>
  401. <text class="step-sub">当前在服药物;如无可勾选「无长期用药」</text>
  402. </view>
  403. </view>
  404. <view class="card">
  405. <view class="quick-chip" :class="{ 'quick-chip-on': innerForm.noMedication }" @tap="toggleNoMedication">
  406. <text>{{ innerForm.noMedication ? '✓ 已选' : '无长期用药' }}</text>
  407. </view>
  408. <text class="unit-hint" v-if="innerForm.noMedication">已标记无长期用药,可跳过药物填写</text>
  409. </view>
  410. <view v-if="!innerForm.noMedication" v-for="(row, idx) in innerForm.medRows" :key="getMedKey(idx)" class="card">
  411. <view class="card-head"><text class="card-title">用药 {{ idx + 1 }}</text>
  412. <text class="card-del" @tap="removeMed(idx)">删除</text>
  413. </view>
  414. <view class="card-row"><text class="card-label">药名</text>
  415. <input class="card-input" type="text" v-model="row.name" placeholder="药物品牌,如 立普妥" /></view>
  416. <view class="card-row"><text class="card-label">开始年月</text>
  417. <picker mode="date" fields="month" :value="row.startDate || currentMonth" :end="monthEnd" @change="onMedStartChange(idx, $event)">
  418. <view class="picker-val">{{ row.startDate ? row.startDate : '选择开始日期' }}</view>
  419. </picker>
  420. <text class="link-forget" @tap="onForgetClick(idx)">记不清</text>
  421. </view>
  422. <view class="card-row" v-if="row.durationText"><text class="card-label">已服时长</text>
  423. <text class="card-value">{{ row.durationText }}</text></view>
  424. <view class="card-row"><text class="card-label">服药时间</text>
  425. <input class="card-input" type="text" v-model="row.time" placeholder="如 早饭后一次" /></view>
  426. </view>
  427. <view class="add-row" v-if="!innerForm.noMedication" @tap="addMed"><text class="add-icon">+</text><text class="add-text">添加药物/治疗</text></view>
  428. </view>
  429. <!-- ===== Step: 过敏源 allergy ===== -->
  430. <view v-if="currentModule === 'allergy'">
  431. <view class="step-head">
  432. <text class="step-icon">⚠️</text>
  433. <view class="step-head-text">
  434. <text class="step-title">过敏源</text>
  435. <text class="step-sub">过敏原与过敏症状均可多选,可自定义添加</text>
  436. </view>
  437. </view>
  438. <view class="card">
  439. <view class="card-head"><text class="card-title">过敏原</text><text class="card-hint">多选</text></view>
  440. <view class="chip-row-wrap">
  441. <view v-for="item in presetAllergies" :key="item" class="chip-item-wrap"
  442. :class="{ 'chip-active': innerDiet.allergies.indexOf(item) >= 0 }"
  443. @tap="toggleDietChip('allergies', item)"><text>{{ item }}</text></view>
  444. </view>
  445. <view class="custom-row" v-if="customField === 'allergies'">
  446. <input class="custom-input" placeholder="输入其他过敏原" v-model="customInput" confirm-type="done" @confirm="addCustom" />
  447. <view class="custom-confirm" @tap="addCustom"><text>添加</text></view>
  448. </view>
  449. <view class="custom-add" v-else @tap="openCustom('allergies')"><text>+ 自定义添加</text></view>
  450. </view>
  451. <view class="card">
  452. <view class="card-head"><text class="card-title">过敏表现</text><text class="card-hint">多选</text></view>
  453. <view class="chip-row-wrap">
  454. <view v-for="opt in allergyOptions" :key="opt" class="chip-item-wrap"
  455. :class="{ 'chip-active': lifestyle.allergySymptoms.indexOf(opt) >= 0 }"
  456. @tap="toggleLifestyleArray('allergySymptoms', opt)"><text>{{ opt }}</text></view>
  457. </view>
  458. </view>
  459. </view>
  460. </view>
  461. <!-- 底部固定栏 -->
  462. <view class="footer-bar">
  463. <button class="btn-prev" v-if="currentStep > 1" @tap="prevStep">上一步</button>
  464. <button class="btn-next" v-if="currentStep < activeModules.length" @tap="nextStep">下一步</button>
  465. <button class="btn-done" v-else :disabled="submitting" @tap="onSubmit">
  466. {{ submitting ? '保存中...' : '完成' }}
  467. </button>
  468. </view>
  469. </view>
  470. </template>
  471. <script>
  472. import { BOWEL_FORM_OPTIONS, normalizeBowelForm } from '@/utils/bowel-form.js'
  473. // ===== 预设常量(与旧子组件 / preferences.vue 保持一致) =====
  474. var PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
  475. var PRESET_AVOID = ['牛肉', '羊肉', '猪肉', '内脏', '油腻', '辛辣', '生冷', '腌制']
  476. var PRESET_FLAVOR = ['酸', '甜', '咸', '鲜', '苦', '清淡', '浓郁']
  477. var PRESET_CUISINE = ['川', '粤', '鲁', '淮扬', '日料', '西餐', '东南亚', '韩餐']
  478. var PRESET_COOKING = ['蒸', '煮', '炒', '烤', '炖', '凉拌', '煎', '炸']
  479. var PRESET_MUSTEAT = ['蔬菜', '水果', '粗粮', '鱼类', '鸡蛋', '豆制品', '菌菇']
  480. var PRESET_GOALS = ['减脂', '增肌', '控糖', '肠道调理', '增强免疫']
  481. var VALID_MODULES = ['basic', 'status', 'diet', 'disease', 'bowel', 'sleep', 'med', 'allergy']
  482. // 8 组 53 项预置疾病(与旧 MedicalHistoryStep.vue 一致,risk=true 为重点关注项)
  483. var DISEASE_GROUPS = [
  484. { key: 'cardio', title: '心血管代谢', items: [
  485. { name: '高血压', risk: false },
  486. { name: '糖尿病', risk: false },
  487. { name: '血脂异常', risk: false },
  488. { name: '冠心病', risk: false },
  489. { name: '脑卒中', risk: false },
  490. { name: '心力衰竭', risk: false },
  491. { name: '心房颤动', risk: false },
  492. { name: '慢阻肺', risk: false },
  493. { name: '心脑血管疾病', risk: true },
  494. { name: 'II型糖尿病', risk: true },
  495. { name: '肥胖', risk: true },
  496. { name: '超重', risk: false },
  497. { name: '高尿酸血症', risk: false },
  498. { name: '骨质疏松', risk: false }
  499. ]},
  500. { key: 'digest', title: '消化系统', items: [
  501. { name: '幽门螺杆菌感染', risk: false },
  502. { name: '脂肪肝', risk: false },
  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: false },
  510. { name: '炎症性肠病', risk: false },
  511. { name: '肝病', risk: true },
  512. { name: '胃病', risk: true },
  513. { name: '胆病', risk: true },
  514. { name: '肾病', risk: true },
  515. { name: '腹胀', risk: true }
  516. ]},
  517. { key: 'immune', title: '过敏免疫', items: [
  518. { name: '过敏性鼻炎', risk: true },
  519. { name: '哮喘', risk: false },
  520. { name: '湿疹', risk: false },
  521. { name: '食物过敏', risk: false },
  522. { name: '自体免疫病', risk: true }
  523. ]},
  524. { key: 'neuro', title: '精神神经', items: [
  525. { name: '抑郁症', risk: true },
  526. { name: '失眠', risk: true },
  527. { name: '焦虑障碍', risk: false },
  528. { name: '偏头痛', risk: false },
  529. { name: '自闭症', risk: true }
  530. ]},
  531. { key: 'resp', title: '呼吸系统', items: [
  532. { name: '肺部感染或疾病', risk: true },
  533. { name: '肺部疾病', risk: true }
  534. ]},
  535. { key: 'tumor', title: '肿瘤', items: [
  536. { name: '结直肠癌', risk: true },
  537. { name: '肺癌', risk: false },
  538. { name: '甲状腺癌', risk: false },
  539. { name: '肝癌', risk: false },
  540. { name: '胃癌', risk: false },
  541. { name: '乳腺癌', risk: false },
  542. { name: '食管癌', risk: false }
  543. ]},
  544. { key: 'child', title: '儿童青少年', items: [
  545. { name: '近视', risk: false },
  546. { name: '龋齿', risk: false },
  547. { name: '注意缺陷多动障碍', risk: false },
  548. { name: '甲状腺疾病', risk: true }
  549. ]},
  550. { key: 'other', title: '其他', items: [
  551. { name: '缺铁性贫血', risk: false },
  552. { name: '贫血', risk: false }
  553. ]}
  554. ]
  555. // 新采集的 lifestyle key(旧数据里可能没有,默认空值)
  556. var LIFESTYLE_KEYS = [
  557. 'currentStatus', 'menstrualCycle', 'specialPeriod', 'growthDevelopment', 'pickyEating',
  558. 'osteoporosisRisk', 'fallRisk', 'screenTime', 'teenMood',
  559. 'bowelFrequency', 'bowelForm', 'exerciseFrequency', 'exerciseDuration',
  560. 'sleepDuration', 'bedtime', 'allergySymptoms',
  561. 'mood', 'antibioticHistory'
  562. ]
  563. // lifestyle key → 所属调研模块(用于「主动清空」时判断是否允许下发 null)
  564. var LIFESTYLE_MODULE_MAP = {
  565. currentStatus: 'status', menstrualCycle: 'status', specialPeriod: 'status',
  566. growthDevelopment: 'status', pickyEating: 'status', osteoporosisRisk: 'status',
  567. fallRisk: 'status', screenTime: 'status', teenMood: 'status',
  568. bowelFrequency: 'bowel', bowelForm: 'bowel', exerciseFrequency: 'bowel', exerciseDuration: 'bowel',
  569. sleepDuration: 'sleep', bedtime: 'sleep',
  570. allergySymptoms: 'allergy',
  571. mood: '', antibioticHistory: ''
  572. }
  573. export default {
  574. name: 'ComprehensiveSurvey',
  575. props: {
  576. // 启用模块 key 列表(顺序决定步骤顺序;空/全非法 → 全量 8 模块)
  577. modules: { type: Array, default: function() { return [] } },
  578. // health_status 快照(结构化字段,lifestyle 为对象非 JSON 串)
  579. form: { type: Object, default: function() { return {} } },
  580. // diet_preferences 快照(13 key)
  581. diet: { type: Object, default: function() { return {} } },
  582. // 成员画像(gender/age 驱动条件渲染)
  583. profile: { type: Object, default: function() { return {} } },
  584. // 外部提交态
  585. submitting: { type: Boolean, default: false }
  586. },
  587. data: function() {
  588. return {
  589. currentStep: 1,
  590. innerForm: {},
  591. innerDiet: {},
  592. lifestyle: {},
  593. // 初始快照的疾病/用药数量(用于区分「主动清空」与「原本为空」)
  594. _initDiseaseCount: 0,
  595. _initMedCount: 0,
  596. currentMonth: '',
  597. monthEnd: '',
  598. expandedGroups: [],
  599. // 自定义输入
  600. customField: '',
  601. customInput: '',
  602. diseaseInput: '',
  603. // 快捷标签选中态(仅 UI 提示,值仍以输入框为准)
  604. heightQuick: '', weightQuick: '', bpQuick: '', glucoseQuick: '', lipidQuick: '',
  605. // 选项常量
  606. presetAllergies: PRESET_ALLERGIES,
  607. presetAvoid: PRESET_AVOID,
  608. presetFlavor: PRESET_FLAVOR,
  609. presetCuisine: PRESET_CUISINE,
  610. presetCooking: PRESET_COOKING,
  611. presetMustEat: PRESET_MUSTEAT,
  612. presetGoals: PRESET_GOALS,
  613. diseaseGroups: DISEASE_GROUPS,
  614. moduleTitles: {
  615. basic: '基本', status: '状态', diet: '饮食', disease: '疾病',
  616. bowel: '排便', sleep: '睡眠', med: '用药', allergy: '过敏'
  617. },
  618. currentStatusOptions: ['正常', '哺乳期', '备孕', '怀孕', '住院期', '恢复期', '其他'],
  619. menstrualOptions: ['规律(28-32天)', '不规律', '已停经', '尚未开始'],
  620. specialPeriodOptions: ['无', '备孕中', '怀孕', '哺乳期'],
  621. growthOptions: ['正常(在生长曲线内)', '偏慢', '偏快', '不清楚'],
  622. pickyEatingOptions: ['无挑食', '轻微挑食', '明显挑食', '严重挑食'],
  623. osteoporosisOptions: ['无风险', '有风险(曾骨折/骨密度低)', '不清楚'],
  624. fallRiskOptions: ['近1年无跌倒', '近1年跌倒1次', '近1年跌倒2次及以上'],
  625. screenTimeOptions: ['<1小时/天', '1-3小时/天', '3-5小时/天', '>5小时/天'],
  626. teenMoodOptions: ['开朗', '一般', '易怒', '低落'],
  627. bowelFreqOptions: ['每天1次(正常)', '隔天1次', '每周2-3次', '每周<2次'],
  628. bowelFormOptions: BOWEL_FORM_OPTIONS,
  629. exerciseFreqOptions: ['不运动', '每周1-2次', '每周3-4次', '每周5次以上'],
  630. exerciseDurationOptions: ['30分钟以内', '30-60分钟', '60分钟以上'],
  631. sleepDurationOptions: ['<6小时', '6-7小时', '7-8小时', '>8小时'],
  632. bedtimeOptions: ['22:00前', '22:00-24:00', '24:00后'],
  633. vegFruitOptions: ['<2份', '2-3份', '4份以上'],
  634. dairyOptions: ['每天', '每周3-5次', '很少', '不喝'],
  635. sugaryDrinkOptions: ['几乎不喝', '每周1-2次', '每周3-5次', '每天1次以上'],
  636. allergyOptions: ['无', '反复皮疹', '鼻痒/打喷嚏', '眼痒/流泪', '哮喘样症状', '食物过敏'],
  637. spiceLabels: ['不辣', '微辣', '中辣', '偏辣', '很辣', '特辣'],
  638. religiousOptions: [
  639. { value: 'none', label: '无限制' },
  640. { value: 'halal', label: '清真' },
  641. { value: 'vegetarian', label: '素食' },
  642. { value: 'vegan', label: '纯素' }
  643. ]
  644. }
  645. },
  646. computed: {
  647. // 过滤非法 key + 兜底全量 + 去重(保持顺序)
  648. activeModules: function() {
  649. var seen = {}
  650. var list = (this.modules || []).filter(function(m) {
  651. if (VALID_MODULES.indexOf(m) < 0 || seen[m]) return false
  652. seen[m] = true
  653. return true
  654. })
  655. if (list.length === 0) list = VALID_MODULES.slice()
  656. return list
  657. },
  658. currentModule: function() {
  659. return this.activeModules[this.currentStep - 1] || ''
  660. },
  661. progressPercent: function() {
  662. return Math.round((this.currentStep / this.activeModules.length) * 100)
  663. },
  664. // 已选疾病名列表(供模板内联 indexOf,避免 :class 带参方法调用)
  665. selectedDiseaseNames: function() {
  666. return (this.innerForm.selectedDiseases || []).map(function(d) { return d.name })
  667. },
  668. isFemale: function() {
  669. var g = this.profile && this.profile.gender
  670. return g === 'female' || g === '女'
  671. },
  672. isChild: function() {
  673. var age = this.profile && this.profile.age
  674. return age != null && Number(age) > 0 && Number(age) < 12
  675. },
  676. isTeenager: function() {
  677. var age = this.profile && this.profile.age
  678. return age != null && Number(age) >= 12 && Number(age) < 18
  679. },
  680. isElderly: function() {
  681. var age = this.profile && this.profile.age
  682. return age != null && Number(age) >= 60
  683. }
  684. },
  685. watch: {
  686. // 仅在 prop 对象身份变化时重建工作副本(父页换快照/切成员 tab);
  687. // 不用 deep:v-model 逐键输入会变更共享行对象,deep 会每次按键全量重建
  688. form: {
  689. handler: function(v) { this.initFromProps() },
  690. immediate: true
  691. },
  692. diet: {
  693. handler: function(v) { this.syncDiet(v) },
  694. immediate: true
  695. }
  696. },
  697. created: function() {
  698. var now = new Date()
  699. var y = now.getFullYear()
  700. var m = now.getMonth() + 1
  701. var mStr = m < 10 ? '0' + m : '' + m
  702. this.currentMonth = y + '-' + mStr
  703. var lastDay = new Date(y, now.getMonth(), 0).getDate()
  704. this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
  705. this.initFromProps()
  706. this.syncDiet(this.diet)
  707. // 默认展开前 3 组 + 已选疾病所在组
  708. this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
  709. var self = this
  710. ;(this.innerForm.selectedDiseases || []).forEach(function(d) {
  711. var gk = self.findGroupKey(d.name)
  712. if (gk && self.expandedGroups.indexOf(gk) < 0) self.expandedGroups.push(gk)
  713. })
  714. },
  715. methods: {
  716. // ===== 初始化 =====
  717. initFromProps: function() {
  718. var f = this.form || {}
  719. this.innerForm = {
  720. heightCm: f.heightCm != null ? f.heightCm : '',
  721. weightKg: f.weightKg != null ? f.weightKg : '',
  722. bloodPressure: f.bloodPressure != null ? f.bloodPressure : '',
  723. bloodGlucose: f.bloodGlucose != null ? f.bloodGlucose : '',
  724. notes: f.notes != null ? f.notes : '',
  725. lipidCholesterol: f.lipidCholesterol != null ? f.lipidCholesterol : '',
  726. selectedDiseases: Array.isArray(f.selectedDiseases) ? f.selectedDiseases.slice() : [],
  727. medRows: Array.isArray(f.medRows) ? f.medRows.slice() : [],
  728. noMedication: !!f.noMedication
  729. }
  730. var ls = f.lifestyle
  731. var obj = {}
  732. if (ls && typeof ls === 'object') {
  733. LIFESTYLE_KEYS.forEach(function(k) {
  734. obj[k] = ls[k] != null ? (ls[k] instanceof Array ? ls[k].slice() : ls[k]) : (k === 'allergySymptoms' ? [] : '')
  735. })
  736. } else {
  737. LIFESTYLE_KEYS.forEach(function(k) {
  738. obj[k] = k === 'allergySymptoms' ? [] : ''
  739. })
  740. }
  741. this.lifestyle = obj
  742. // 旧版 5 档粪便性状文案 → 布里斯托 7 型(存量数据归一,避免回显不匹配)
  743. if (obj.bowelForm) this.$set(this.lifestyle, 'bowelForm', normalizeBowelForm(obj.bowelForm))
  744. this._initDiseaseCount = (this.innerForm.selectedDiseases || []).length
  745. this._initMedCount = (this.innerForm.medRows || []).length
  746. // 快捷标签回显
  747. this.matchQuickLabels()
  748. },
  749. syncDiet: function(v) {
  750. var d = v || {}
  751. this.innerDiet = {
  752. allergies: Array.isArray(d.allergies) ? d.allergies.slice() : [],
  753. absoluteAvoid: Array.isArray(d.absoluteAvoid) ? d.absoluteAvoid.slice() : [],
  754. mustEat: Array.isArray(d.mustEat) ? d.mustEat.slice() : [],
  755. religiousDiet: d.religiousDiet || 'none',
  756. spiceLevel: d.spiceLevel != null ? d.spiceLevel : 0,
  757. flavorPref: Array.isArray(d.flavorPref) ? d.flavorPref.slice() : [],
  758. cuisinePref: Array.isArray(d.cuisinePref) ? d.cuisinePref.slice() : [],
  759. cookingMethods: Array.isArray(d.cookingMethods) ? d.cookingMethods.slice() : [],
  760. healthGoals: Array.isArray(d.healthGoals) ? d.healthGoals.slice() : [],
  761. noPreference: !!d.noPreference,
  762. vegetableFruit: d.vegetableFruit || '',
  763. dairy: d.dairy || '',
  764. sugaryDrinks: d.sugaryDrinks || ''
  765. }
  766. },
  767. matchQuickLabels: function() {
  768. this.heightQuick = this.matchQuick('heightCm', 'height')
  769. this.weightQuick = this.matchQuick('weightKg', 'weight')
  770. this.bpQuick = this.matchQuick('bloodPressure', 'bloodPressure')
  771. this.glucoseQuick = this.matchQuick('bloodGlucose', 'bloodGlucose')
  772. this.lipidQuick = this.matchQuick('lipidCholesterol', 'lipid')
  773. },
  774. matchQuick: function(field, quickKey) {
  775. var QUICK = {
  776. height: { normal: '170', high: '185', low: '155' },
  777. weight: { normal: '60', high: '80', low: '45' },
  778. bloodPressure: { normal: '120/80', high: '140/90', low: '90/60' },
  779. bloodGlucose: { normal: '5.0', high: '7.0', low: '3.5' },
  780. lipid: { normal: '4.5', high: '6.2' }
  781. }
  782. var map = QUICK[quickKey]
  783. var val = String(this.innerForm[field] || '')
  784. var keys = Object.keys(map)
  785. for (var i = 0; i < keys.length; i++) {
  786. if (String(map[keys[i]]) === val) return keys[i]
  787. }
  788. return ''
  789. },
  790. // ===== 步骤导航(前进步时同步快照到父页) =====
  791. prevStep: function() { if (this.currentStep > 1) this.currentStep-- },
  792. nextStep: function() {
  793. if (this.currentStep < this.activeModules.length) {
  794. this.$emit('update-form', this.formCopy())
  795. this.$emit('update-diet', this.dietCopy())
  796. this.currentStep++
  797. }
  798. },
  799. // ===== 提交 =====
  800. onSubmit: function() {
  801. if (this.submitting) return
  802. // 数值校验(与旧页一致)
  803. if (this.activeModules.indexOf('basic') >= 0) {
  804. var h = Number(this.innerForm.heightCm)
  805. var w = Number(this.innerForm.weightKg)
  806. var g = Number(this.innerForm.bloodGlucose)
  807. var bp = this.innerForm.bloodPressure
  808. if (this.innerForm.heightCm && (h < 30 || h > 250)) {
  809. uni.showToast({ title: '身高应在 30-250 之间', icon: 'none' }); return
  810. }
  811. if (this.innerForm.weightKg && (w < 3 || w > 300)) {
  812. uni.showToast({ title: '体重应在 3-300 之间', icon: 'none' }); return
  813. }
  814. if (this.innerForm.bloodGlucose && (g < 1 || g > 30)) {
  815. uni.showToast({ title: '血糖应在 1-30 之间', icon: 'none' }); return
  816. }
  817. if (bp && !/^\d{2,3}\/\d{2,3}$/.test(bp)) {
  818. uni.showToast({ title: '血压格式应为 120/80', icon: 'none' }); return
  819. }
  820. }
  821. // 空提交拦截
  822. var formCopy = this.formCopy()
  823. var dietCopy = this.dietCopy()
  824. var diff = this.buildLifestyleDiff()
  825. if (!this.hasAnyContent(formCopy, dietCopy, diff)) {
  826. uni.showToast({ title: '请至少填写一项', icon: 'none' }); return
  827. }
  828. this.$emit('submit', { form: formCopy, diet: dietCopy, lifestyleDiff: diff })
  829. },
  830. hasAnyContent: function(form, diet, diff) {
  831. var keys = ['heightCm', 'weightKg', 'bloodPressure', 'bloodGlucose', 'notes']
  832. for (var i = 0; i < keys.length; i++) {
  833. if (form[keys[i]] != null && form[keys[i]] !== '') return true
  834. }
  835. if (form.lipidCholesterol != null && form.lipidCholesterol !== '') return true
  836. if (form.selectedDiseases && form.selectedDiseases.length > 0) return true
  837. if (form.medRows && form.medRows.length > 0) return true
  838. if (form.noMedication) return true
  839. var dkeys = ['allergies', 'absoluteAvoid', 'mustEat', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals', 'vegetableFruit', 'dairy', 'sugaryDrinks']
  840. for (var j = 0; j < dkeys.length; j++) {
  841. var dv = diet[dkeys[j]]
  842. if (dv instanceof Array) { if (dv.length > 0) return true } else if (dv != null && dv !== '' && dv !== 'none') return true
  843. }
  844. if (diet.religiousDiet && diet.religiousDiet !== 'none') return true
  845. if (diet.spiceLevel && Number(diet.spiceLevel) > 0) return true
  846. if (diet.noPreference) return true
  847. return Object.keys(diff).length > 0
  848. },
  849. // 结构化 form 副本(不含 lifestyle;JSON 转换由父页负责)
  850. formCopy: function() {
  851. var diseases = this.innerForm.selectedDiseases || []
  852. var meds = this.innerForm.medRows || []
  853. return {
  854. heightCm: this.innerForm.heightCm,
  855. weightKg: this.innerForm.weightKg,
  856. bloodPressure: this.innerForm.bloodPressure,
  857. bloodGlucose: this.innerForm.bloodGlucose,
  858. notes: this.innerForm.notes,
  859. lipidCholesterol: this.innerForm.lipidCholesterol,
  860. selectedDiseases: diseases.slice(),
  861. medRows: meds.slice(),
  862. noMedication: !!this.innerForm.noMedication,
  863. // 原本有数据但被用户删空 → 置位,父页下发 '[]' 落库(否则旧值回滚)
  864. diseasesCleared: this._initDiseaseCount > 0 && diseases.length === 0,
  865. medsCleared: this._initMedCount > 0 && meds.length === 0
  866. }
  867. },
  868. dietCopy: function() {
  869. return {
  870. allergies: this.innerDiet.allergies || [],
  871. absoluteAvoid: this.innerDiet.absoluteAvoid || [],
  872. mustEat: this.innerDiet.mustEat || [],
  873. religiousDiet: this.innerDiet.religiousDiet || 'none',
  874. spiceLevel: this.innerDiet.spiceLevel || 0,
  875. flavorPref: this.innerDiet.flavorPref || [],
  876. cuisinePref: this.innerDiet.cuisinePref || [],
  877. cookingMethods: this.innerDiet.cookingMethods || [],
  878. healthGoals: this.innerDiet.healthGoals || [],
  879. noPreference: !!this.innerDiet.noPreference,
  880. vegetableFruit: this.innerDiet.vegetableFruit || '',
  881. dairy: this.innerDiet.dairy || '',
  882. sugaryDrinks: this.innerDiet.sugaryDrinks || ''
  883. }
  884. },
  885. // lifestyle diff:仅本次修改过的 key(未变化的 key 剔除,交由父页 merge 旧值)
  886. buildLifestyleDiff: function() {
  887. var diff = {}
  888. var orig = (this.form && this.form.lifestyle) || {}
  889. var self = this
  890. LIFESTYLE_KEYS.forEach(function(k) {
  891. var val = self.lifestyle[k]
  892. if (val == null) return
  893. var a = JSON.stringify(val)
  894. var b = JSON.stringify(orig[k] != null ? orig[k] : (k === 'allergySymptoms' ? [] : null))
  895. if (a === b) return
  896. if (val instanceof Array) { if (val.length > 0) diff[k] = val.slice() }
  897. else if (val !== '') diff[k] = val
  898. // 主动清空:值被删空且该 key 所属模块已启用 → 下发 null 让父页落库清除(未启用模块不清,保护旧数据)
  899. else if (val === '' && orig[k] && self.activeModules.indexOf(LIFESTYLE_MODULE_MAP[k]) >= 0) {
  900. diff[k] = null
  901. }
  902. })
  903. return diff
  904. },
  905. // ===== lifestyle 工具 =====
  906. setLifestyle: function(key, val) {
  907. this.$set(this.lifestyle, key, this.lifestyle[key] === val ? '' : val)
  908. },
  909. toggleLifestyleArray: function(key, val) {
  910. var arr = (this.lifestyle[key] || []).slice()
  911. var idx = arr.indexOf(val)
  912. if (idx >= 0) arr.splice(idx, 1); else arr.push(val)
  913. this.$set(this.lifestyle, key, arr)
  914. },
  915. // ===== diet 工具 =====
  916. setDietValue: function(key, val) {
  917. this.$set(this.innerDiet, key, val)
  918. },
  919. toggleDietChip: function(key, val) {
  920. var arr = (this.innerDiet[key] || []).slice()
  921. var idx = arr.indexOf(val)
  922. if (idx >= 0) arr.splice(idx, 1); else arr.push(val)
  923. this.$set(this.innerDiet, key, arr)
  924. this.$set(this.innerDiet, 'noPreference', false)
  925. },
  926. setNoPreference: function() {
  927. this.$set(this.innerDiet, 'noPreference', !this.innerDiet.noPreference)
  928. },
  929. onSpiceChanging: function(e) { this.$set(this.innerDiet, 'spiceLevel', e.detail.value) },
  930. onSpiceChange: function(e) {
  931. this.$set(this.innerDiet, 'spiceLevel', e.detail.value)
  932. this.$set(this.innerDiet, 'noPreference', false)
  933. },
  934. // ===== 自定义添加 =====
  935. openCustom: function(field) { this.customField = field; this.customInput = '' },
  936. addCustom: function() {
  937. var val = (this.customInput || '').trim()
  938. if (!val) { this.customField = ''; return }
  939. var arr = (this.innerDiet[this.customField] || []).slice()
  940. if (arr.indexOf(val) < 0) {
  941. arr.push(val)
  942. this.$set(this.innerDiet, this.customField, arr)
  943. }
  944. this.$set(this.innerDiet, 'noPreference', false)
  945. this.customInput = ''
  946. this.customField = ''
  947. },
  948. // ===== 体征快捷标签 =====
  949. setHeightQuick: function(key) {
  950. var v = { normal: '170', high: '185', low: '155' }[key]
  951. this.$set(this.innerForm, 'heightCm', v); this.heightQuick = key
  952. },
  953. setWeightQuick: function(key) {
  954. var v = { normal: '60', high: '80', low: '45' }[key]
  955. this.$set(this.innerForm, 'weightKg', v); this.weightQuick = key
  956. },
  957. setBpQuick: function(key) {
  958. var v = { normal: '120/80', high: '140/90', low: '90/60' }[key]
  959. this.$set(this.innerForm, 'bloodPressure', v); this.bpQuick = key
  960. },
  961. setGlucoseQuick: function(key) {
  962. var v = { normal: '5.0', high: '7.0', low: '3.5' }[key]
  963. this.$set(this.innerForm, 'bloodGlucose', v); this.glucoseQuick = key
  964. },
  965. setLipidQuick: function(key) {
  966. var v = { normal: '4.5', high: '6.2' }[key]
  967. this.$set(this.innerForm, 'lipidCholesterol', v); this.lipidQuick = key
  968. },
  969. // ===== v-for key 方法(微信小程序 :key 不支持表达式,须用方法调用) =====
  970. getDiseaseKey: function(gi, ii) { return 'g' + gi + '-' + ii },
  971. getSelKey: function(si) { return 's' + si },
  972. getMedKey: function(idx) { return 'm' + idx },
  973. // ===== 疾病 =====
  974. findGroupKey: function(name) {
  975. for (var i = 0; i < DISEASE_GROUPS.length; i++) {
  976. var items = DISEASE_GROUPS[i].items
  977. for (var j = 0; j < items.length; j++) {
  978. if (items[j].name === name) return DISEASE_GROUPS[i].key
  979. }
  980. }
  981. return ''
  982. },
  983. toggleGroup: function(key) {
  984. var idx = this.expandedGroups.indexOf(key)
  985. if (idx >= 0) this.expandedGroups.splice(idx, 1); else this.expandedGroups.push(key)
  986. },
  987. isDiseaseSelected: function(name) {
  988. return (this.innerForm.selectedDiseases || []).some(function(d) { return d.name === name })
  989. },
  990. toggleDisease: function(name) {
  991. var list = (this.innerForm.selectedDiseases || []).slice()
  992. var idx = -1
  993. for (var i = 0; i < list.length; i++) { if (list[i].name === name) { idx = i; break } }
  994. if (idx >= 0) list.splice(idx, 1); else list.push({ name: name, diagnosedAt: '', custom: false })
  995. this.$set(this.innerForm, 'selectedDiseases', list)
  996. },
  997. addCustomDisease: function() {
  998. var name = (this.diseaseInput || '').trim()
  999. if (!name) return
  1000. var list = (this.innerForm.selectedDiseases || []).slice()
  1001. if (!list.some(function(d) { return d.name === name })) {
  1002. list.push({ name: name, diagnosedAt: '', custom: true })
  1003. this.$set(this.innerForm, 'selectedDiseases', list)
  1004. }
  1005. this.diseaseInput = ''
  1006. },
  1007. removeSelected: function(idx) {
  1008. var list = (this.innerForm.selectedDiseases || []).slice()
  1009. list.splice(idx, 1)
  1010. this.$set(this.innerForm, 'selectedDiseases', list)
  1011. },
  1012. onDiagChange: function(idx, e) {
  1013. var list = (this.innerForm.selectedDiseases || []).slice()
  1014. list[idx] = Object.assign({}, list[idx], { diagnosedAt: e.detail.value })
  1015. this.$set(this.innerForm, 'selectedDiseases', list)
  1016. },
  1017. // ===== 用药 =====
  1018. toggleNoMedication: function() {
  1019. this.$set(this.innerForm, 'noMedication', !this.innerForm.noMedication)
  1020. },
  1021. addMed: function() {
  1022. var rows = (this.innerForm.medRows || []).slice()
  1023. rows.push({ name: '', startDate: '', durationText: '', time: '' })
  1024. this.$set(this.innerForm, 'medRows', rows)
  1025. },
  1026. removeMed: function(idx) {
  1027. var rows = (this.innerForm.medRows || []).slice()
  1028. rows.splice(idx, 1)
  1029. this.$set(this.innerForm, 'medRows', rows)
  1030. },
  1031. onMedStartChange: function(idx, e) {
  1032. var start = e.detail.value
  1033. var rows = (this.innerForm.medRows || []).slice()
  1034. rows[idx] = Object.assign({}, rows[idx], { startDate: start, durationText: this.calcDuration(start) })
  1035. this.$set(this.innerForm, 'medRows', rows)
  1036. },
  1037. onForgetClick: function(idx) {
  1038. var self = this
  1039. uni.showModal({
  1040. title: '记不清开始日期',
  1041. editable: true,
  1042. placeholderText: '如:约 3 个月',
  1043. success: function(res) {
  1044. if (res.confirm && res.content) {
  1045. var rows = (self.innerForm.medRows || []).slice()
  1046. rows[idx] = Object.assign({}, rows[idx], { durationText: res.content.trim() })
  1047. self.$set(self.innerForm, 'medRows', rows)
  1048. }
  1049. }
  1050. })
  1051. },
  1052. calcDuration: function(startDate) {
  1053. if (!startDate) return ''
  1054. var parts = startDate.split('-')
  1055. if (parts.length !== 2) return ''
  1056. var sy = parseInt(parts[0], 10)
  1057. var sm = parseInt(parts[1], 10)
  1058. var now = new Date()
  1059. var months = (now.getFullYear() - sy) * 12 + (now.getMonth() + 1 - sm)
  1060. if (months < 1) months = 1
  1061. if (months < 12) return '已服 ' + months + ' 个月'
  1062. return '已服 ' + Math.floor(months / 12) + ' 年'
  1063. }
  1064. }
  1065. }
  1066. </script>
  1067. <style scoped>
  1068. .survey-wrap { min-height: 100vh; background: linear-gradient(180deg, #FFF7ED, #FFF3E6); }
  1069. .progress-bar { padding: 24rpx 32rpx 8rpx; background: #FFF7ED; }
  1070. .progress-track { height: 8rpx; background: #FFE6D2; border-radius: 4rpx; overflow: hidden; }
  1071. .progress-fill { height: 100%; background: #FF8C42; border-radius: 4rpx; transition: width 0.3s; }
  1072. .progress-labels { display: flex; justify-content: space-between; margin-top: 12rpx; }
  1073. .progress-label { font-size: 20rpx; color: #B08968; }
  1074. .progress-label-active { color: #FF8C42; font-weight: 600; }
  1075. .content { padding: 24rpx 32rpx 220rpx; }
  1076. .step-head { display: flex; align-items: flex-start; margin-bottom: 24rpx; }
  1077. .step-icon { font-size: 48rpx; margin-right: 20rpx; }
  1078. .step-head-text { flex: 1; }
  1079. .step-title { display: block; font-size: 36rpx; font-weight: 700; color: #4A3728; }
  1080. .step-sub { display: block; font-size: 22rpx; color: #B08968; margin-top: 8rpx; line-height: 1.5; }
  1081. .card { background: #FFFFFF; border-radius: 24rpx; padding: 28rpx; margin-bottom: 24rpx; box-shadow: 0 4rpx 16rpx rgba(255, 140, 66, 0.08); }
  1082. .card-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; }
  1083. .card-title { display: block; font-size: 28rpx; font-weight: 600; color: #4A3728; margin-bottom: 16rpx; }
  1084. .card-head .card-title { margin-bottom: 0; }
  1085. .card-hint { font-size: 22rpx; color: #B08968; margin-bottom: 16rpx; }
  1086. .card-del { font-size: 24rpx; color: #E53E3E; padding: 8rpx 16rpx; }
  1087. .card-value { font-size: 26rpx; color: #4A3728; }
  1088. .group-arrow { font-size: 22rpx; color: #B08968; }
  1089. .card-row { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #F7EFE6; }
  1090. .card-row:last-child { border-bottom: none; }
  1091. .card-label { font-size: 26rpx; color: #6B5744; }
  1092. .card-input { flex: 1; text-align: right; font-size: 28rpx; color: #4A3728; }
  1093. .picker-val { font-size: 26rpx; color: #FF8C42; }
  1094. .num-input { display: block; width: 100%; background: #FFF3E6; border-radius: 16rpx; padding: 16rpx 20rpx; font-size: 28rpx; color: #4A3728; margin-top: 16rpx; }
  1095. .unit-hint { display: block; font-size: 20rpx; color: #C4A98E; margin-top: 8rpx; }
  1096. .notes-area { width: 100%; min-height: 120rpx; background: #FFF3E6; border-radius: 16rpx; padding: 16rpx 20rpx; font-size: 26rpx; color: #4A3728; margin-top: 16rpx; }
  1097. .quick-chips { display: flex; gap: 12rpx; }
  1098. .quick-chip { padding: 8rpx 20rpx; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 20rpx; border: 2rpx solid #FFE6D2; }
  1099. .quick-chip-on { background: #FF8C42; color: #FFFFFF; border-color: #FF8C42; }
  1100. .chip-row-wrap { display: flex; flex-wrap: wrap; gap: 16rpx; }
  1101. .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; }
  1102. .chip-item-wrap.chip-active { background: #FF8C42; color: #FFFFFF; border-color: #FF8C42; font-weight: 600; }
  1103. /* ===== 粪便性状(布里斯托 7 型图片选择) ===== */
  1104. .stool-tip { font-size: 22rpx; color: #B08968; margin-bottom: 16rpx; }
  1105. .stool-grid { display: flex; flex-wrap: wrap; justify-content: space-between; }
  1106. .stool-item {
  1107. width: 48.5%;
  1108. box-sizing: border-box;
  1109. display: flex;
  1110. flex-direction: column;
  1111. align-items: center;
  1112. padding: 16rpx 12rpx;
  1113. margin-bottom: 16rpx;
  1114. background: #FFFDFA;
  1115. border: 2rpx solid #F0E4D6;
  1116. border-radius: 16rpx;
  1117. }
  1118. .stool-item.stool-active { background: #FFF3E6; border-color: #FF8C42; }
  1119. .stool-img { width: 130rpx; height: 130rpx; }
  1120. .stool-name { font-size: 24rpx; font-weight: 600; color: #4A3728; margin-top: 10rpx; text-align: center; }
  1121. .stool-active .stool-name { color: #FF8C42; }
  1122. .stool-desc { font-size: 20rpx; color: #B08968; margin-top: 4rpx; line-height: 1.4; text-align: center; }
  1123. .risk-badge { color: #E53E3E; font-size: 20rpx; }
  1124. .chip-active .risk-badge { color: #FFE6D2; }
  1125. .custom-row { display: flex; align-items: center; margin-top: 16rpx; gap: 16rpx; }
  1126. .custom-input { flex: 1; background: #FFF3E6; border-radius: 16rpx; padding: 16rpx 20rpx; font-size: 24rpx; color: #4A3728; }
  1127. .custom-confirm { padding: 12rpx 24rpx; background: #FF8C42; color: #FFF; border-radius: 16rpx; font-size: 24rpx; }
  1128. .custom-add { margin-top: 16rpx; font-size: 24rpx; color: #FF8C42; }
  1129. .struct-row { margin-bottom: 24rpx; }
  1130. .struct-row:last-child { margin-bottom: 0; }
  1131. .struct-label { display: block; font-size: 24rpx; color: #6B5744; margin-bottom: 12rpx; }
  1132. .slider-row { display: flex; align-items: center; gap: 24rpx; }
  1133. .sel-row { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #F7EFE6; }
  1134. .sel-row:last-child { border-bottom: none; }
  1135. .sel-name { flex: 1; font-size: 26rpx; color: #4A3728; }
  1136. .sel-diag { font-size: 24rpx; color: #FF8C42; padding: 8rpx 20rpx; background: #FFF3E6; border-radius: 999rpx; margin: 0 16rpx; }
  1137. .sel-del { font-size: 32rpx; color: #B08968; padding: 0 12rpx; }
  1138. .link-forget { font-size: 24rpx; color: #FF8C42; padding: 8rpx 16rpx; }
  1139. .pref-none-row { margin-bottom: 24rpx; }
  1140. .pref-none-btn { width: 100%; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 26rpx; padding: 16rpx; border: 2rpx solid #FFD4B8; }
  1141. .pref-none-btn-active { background: #FF8C42; color: #FFF; border-color: #FF8C42; }
  1142. .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; }
  1143. .add-icon { margin-right: 8rpx; }
  1144. .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; }
  1145. .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; }
  1146. .btn-next, .btn-done { background: #FF8C42; color: #FFFFFF; }
  1147. .btn-done[disabled] { opacity: 0.6; }
  1148. </style>