gut-flora-detail.vue 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013
  1. <template>
  2. <view class="page">
  3. <scroll-view class="content" scroll-y>
  4. <!-- 报告头部 -->
  5. <view class="report-header">
  6. <text class="report-title">肠道菌群检测报告</text>
  7. <view class="report-meta">
  8. <text class="meta-item" v-if="report.personName">{{ report.personName }}</text>
  9. <text class="meta-item" v-if="report.gender">{{ report.gender }}</text>
  10. <text class="meta-item" v-if="report.age">{{ report.age }}岁</text>
  11. </view>
  12. <view class="report-meta">
  13. <text class="meta-item" v-if="report.reportDate">日期: {{ reportDateText }}</text>
  14. <text class="meta-item" v-if="report.reportNumber">编号: {{ report.reportNumber }}</text>
  15. </view>
  16. <view class="badge-row" v-if="report.gutAge || report.gutType || report.shannonIndex">
  17. <text class="badge" v-if="report.gutAge">肠龄: {{ report.gutAge }}</text>
  18. <text class="badge" v-if="report.gutType">肠型: {{ report.gutType }}</text>
  19. <text class="badge" v-if="report.shannonIndex">多样性指数: {{ report.shannonIndex }}</text>
  20. </view>
  21. <view class="rating-line" v-if="report.healthRating">
  22. <text class="rating-text">综合评级: {{ report.healthRating }}</text>
  23. </view>
  24. <!-- 报告解读 -->
  25. <view class="interpretation-box" v-if="reportInterpretation && reportInterpretation.gutAgeAssessment">
  26. <text class="interp-text">{{ reportInterpretation.gutAgeAssessment }}</text>
  27. </view>
  28. </view>
  29. <!-- 评分圆环(折行显示,无横向滚动) -->
  30. <view class="scores-wrap">
  31. <view class="score-item" v-for="(score, idx) in scoreItems" :key="idx">
  32. <view class="score-ring" :style="'border-color: ' + score.color + '; color: ' + score.color">
  33. <text class="score-val">{{ score.value }}</text>
  34. </view>
  35. <text class="score-label">{{ score.label }}</text>
  36. </view>
  37. </view>
  38. <!-- 快速跳转 -->
  39. <view class="quick-nav">
  40. <view class="nav-card" @tap="goToSpecies">
  41. <text class="nav-icon">🦠</text>
  42. <text class="nav-label">菌群详情</text>
  43. <text class="nav-count">{{ floraAbnormalCount }}/{{ floraCount }}种</text>
  44. </view>
  45. <view class="nav-card" @tap="goToPathogen">
  46. <text class="nav-icon">🔬</text>
  47. <text class="nav-label">病原菌</text>
  48. <text class="nav-count">{{ pathogenCount }}项</text>
  49. </view>
  50. <view class="nav-card" @tap="goToRisks">
  51. <text class="nav-icon">⚠️</text>
  52. <text class="nav-label">疾病风险</text>
  53. <text class="nav-count">{{ riskAttentionCount }}/{{ riskCount }}项</text>
  54. </view>
  55. <view class="nav-card" @tap="goToFoods">
  56. <text class="nav-icon">🍽️</text>
  57. <text class="nav-label">饮食推荐</text>
  58. <text class="nav-count">{{ foodCount }}项</text>
  59. </view>
  60. <view class="nav-card caution-card" @tap="goToCaution" v-if="cautionCount > 0">
  61. <text class="nav-icon">⚠️</text>
  62. <text class="nav-label">食材戒备</text>
  63. <text class="nav-count caution-count">{{ cautionCount }}项</text>
  64. </view>
  65. </view>
  66. <!-- 菌群功能分析 -->
  67. <view class="analysis-section" v-if="analysisData">
  68. <view class="section-title-bar">
  69. <text class="analysis-title">🧬 菌群功能分析</text>
  70. <text class="analysis-subtitle">基于知识库的功能维度解读</text>
  71. </view>
  72. <!-- 功能维度聚合 -->
  73. <view class="analysis-card">
  74. <text class="card-label">功能维度评估</text>
  75. <view class="func-dim" v-for="(dim, di) in analysisData.functionalAnalysis" :key="di">
  76. <view class="func-dim-head">
  77. <text class="func-dim-name">{{ dim.name }}</text>
  78. <text :class="'func-badge func-' + statusKey(dim.status)">{{ dim.status }}</text>
  79. </view>
  80. <view class="func-bar">
  81. <view class="func-bar-fill" :class="'fill-' + statusKey(dim.status)" :style="'width:' + dim.level + '%'"></view>
  82. </view>
  83. <text class="func-dim-summary">{{ dim.summary }}</text>
  84. </view>
  85. </view>
  86. <!-- P/B 比值 -->
  87. <view class="analysis-card" v-if="analysisData.pbRatio && analysisData.pbRatio.ratio !== undefined">
  88. <text class="card-label">饮食模式(P/B 比值)</text>
  89. <view class="pb-row">
  90. <text class="pb-label">Prevotella: {{ analysisData.pbRatio.prevotellaValue }}</text>
  91. <text class="pb-label">Bacteroides: {{ analysisData.pbRatio.bacteroidesValue }}</text>
  92. <text class="pb-ratio">比值 {{ analysisData.pbRatio.ratio }}</text>
  93. </view>
  94. <text class="func-dim-summary">{{ analysisData.pbRatio.interpretation }}</text>
  95. </view>
  96. <!-- 饮食模式推断 -->
  97. <view class="analysis-card" v-if="analysisData.dietaryPattern">
  98. <text class="card-label">饮食模式推断</text>
  99. <text class="pattern-name">{{ analysisData.dietaryPattern.pattern }}</text>
  100. <text class="func-dim-summary">{{ analysisData.dietaryPattern.detail }}</text>
  101. </view>
  102. <!-- 风险提示 -->
  103. <view class="analysis-card">
  104. <text class="card-label">风险提示</text>
  105. <view class="risk-item" v-for="(risk, ri) in analysisData.riskIndicators" :key="ri">
  106. <text class="risk-name">{{ risk.name }}</text>
  107. <text :class="'risk-level risk-' + statusKey(risk.level)">{{ risk.level }}</text>
  108. <text class="risk-bacteria" v-if="risk.relatedBacteria && risk.relatedBacteria.length > 0">
  109. {{ risk.relatedBacteria.join('、') }}
  110. </text>
  111. </view>
  112. </view>
  113. <!-- 益生菌指导 -->
  114. <view class="analysis-card" v-if="analysisData.probioticAdvice">
  115. <text class="card-label">益生菌指导</text>
  116. <view class="probiotic-item" v-for="(advice, key) in analysisData.probioticAdvice" :key="key">
  117. <text class="probiotic-name">{{ key === 'bifidobacterium' ? '双歧杆菌' : '乳杆菌' }}</text>
  118. <text :class="'func-badge func-' + statusKey(advice.status)">{{ advice.status }}</text>
  119. <text class="probiotic-advice">{{ advice.advice }}</text>
  120. </view>
  121. </view>
  122. </view>
  123. <!-- 肠道功能手风琴 -->
  124. <view class="accordion-section">
  125. <view class="accordion-header" @tap="toggleAccordion('gut')">
  126. <text class="accordion-title">肠道功能与代谢物</text>
  127. <text class="accordion-stats" v-if="gutStats.total > 0">有效 {{ gutStats.valid }} / 异常 {{ gutStats.abnormal }} / 共 {{ gutStats.total }} 项</text>
  128. <text class="accordion-arrow">{{ accordionOpen.gut ? '收起' : '展开' }}</text>
  129. </view>
  130. <view class="accordion-body" v-if="accordionOpen.gut">
  131. <view class="indicator-card" v-for="(item, idx) in gutIndicators" :key="idx">
  132. <view class="indicator-row">
  133. <text class="ind-help-btn" @tap.stop="showKnowledge(indicatorType(item), item.indicatorName)">?</text>
  134. <text class="indicator-name" @tap="showKnowledge(indicatorType(item), item.indicatorName)">
  135. {{ item.indicatorName }}
  136. <text class="arrow-up" v-if="item.status === '偏高' || item.status === '过多'"> ↑</text>
  137. <text class="arrow-down" v-else-if="item.status === '偏低' || item.status === '缺乏' || item.status === '不足'"> ↓</text>
  138. <text class="arrow-warn" v-else-if="item.status === '异常'"> ⚠</text>
  139. </text>
  140. <view class="indicator-value" v-if="!isEditing">
  141. <text :class="'val-text val-' + (item._cssClass || 'normal')">{{ item.indicatorValue || '--' }}</text>
  142. <text class="indicator-unit" v-if="item.unit">{{ item.unit }}</text>
  143. </view>
  144. <view class="indicator-edit" v-if="isEditing">
  145. <input class="edit-input" type="text" v-model="item.indicatorValue" />
  146. <picker :value="indStatusIndex(item.status)" :range="indStatuses" @change="onIndStatusChange($event, item)">
  147. <text class="edit-picker">{{ item.status || '正常' }}</text>
  148. </picker>
  149. </view>
  150. </view>
  151. <text class="indicator-range" v-if="item.refRange">参考: {{ item.refRange }}</text>
  152. <text class="indicator-symptoms" v-if="item.symptoms">症状: {{ item.symptoms }}</text>
  153. </view>
  154. <view class="empty-hint" v-if="gutIndicators.length === 0">暂无数据</view>
  155. </view>
  156. </view>
  157. <!-- 营养指标手风琴 -->
  158. <view class="accordion-section">
  159. <view class="accordion-header" @tap="toggleAccordion('nutri')">
  160. <text class="accordion-title">营养指标</text>
  161. <text class="accordion-stats" v-if="nutriStats.total > 0">有效 {{ nutriStats.valid }} / 异常 {{ nutriStats.abnormal }} / 共 {{ nutriStats.total }} 项</text>
  162. <text class="accordion-arrow">{{ accordionOpen.nutri ? '收起' : '展开' }}</text>
  163. </view>
  164. <view class="accordion-body" v-if="accordionOpen.nutri">
  165. <view class="sub-group" v-for="(group, gIdx) in nutritionGroups" :key="gIdx">
  166. <text class="sub-group-title">{{ group.category }}</text>
  167. <view class="indicator-card" v-for="(item, idx) in group.items" :key="idx">
  168. <view class="indicator-row">
  169. <text class="ind-help-btn" @tap.stop="showKnowledge(indicatorType(item), item.indicatorName)">?</text>
  170. <text class="indicator-name" @tap="showKnowledge(indicatorType(item), item.indicatorName)">
  171. {{ item.indicatorName }}
  172. <text class="arrow-up" v-if="item.status === '偏高' || item.status === '过多'"> ↑</text>
  173. <text class="arrow-down" v-else-if="item.status === '偏低' || item.status === '缺乏' || item.status === '不足'"> ↓</text>
  174. <text class="arrow-warn" v-else-if="item.status === '异常'"> ⚠</text>
  175. </text>
  176. <view class="indicator-value" v-if="!isEditing">
  177. <text :class="'val-text val-' + (item._cssClass || 'normal')">{{ item.indicatorValue || '--' }}</text>
  178. <text class="indicator-unit" v-if="item.unit">{{ item.unit }}</text>
  179. </view>
  180. <view class="indicator-edit" v-if="isEditing">
  181. <input class="edit-input" type="text" v-model="item.indicatorValue" />
  182. <picker :value="indStatusIndex(item.status)" :range="indStatuses" @change="onIndStatusChange($event, item)">
  183. <text class="edit-picker">{{ item.status || '正常' }}</text>
  184. </picker>
  185. </view>
  186. </view>
  187. <text class="indicator-range" v-if="item.refRange">参考: {{ item.refRange }}</text>
  188. <text class="indicator-symptoms" v-if="item.symptoms">症状: {{ item.symptoms }}</text>
  189. </view>
  190. </view>
  191. <view class="empty-hint" v-if="nutritionGroups.length === 0">暂无数据</view>
  192. </view>
  193. </view>
  194. <view class="feedback-section" @tap="openFeedback">
  195. <text class="feedback-icon">💬</text>
  196. <text class="feedback-text">对这个报告有疑问或建议?告诉我们</text>
  197. <text class="feedback-arrow">›</text>
  198. </view>
  199. <view class="bottom-spacer"></view>
  200. </scroll-view>
  201. <health-knowledge-popup :visible="knowledgeVisible" :data="knowledgeData" @close="closeKnowledge" />
  202. <feedback-popup :visible="feedbackVisible" :report-id="reportId" @close="closeFeedback" />
  203. <view class="fab" v-if="!isEditing && !isDraftMode" @tap="toggleEdit">
  204. <text class="fab-icon">✏️</text>
  205. </view>
  206. <view class="bottom-bar" v-if="isEditing">
  207. <view class="btn-cancel" @tap="cancelEdit">取消</view>
  208. <view class="btn-save" @tap="saveEdit">保存</view>
  209. </view>
  210. <view class="draft-bar" v-if="isDraftMode && !confirming && !parsing">
  211. <view class="btn-discard" @tap="doDiscard">放弃</view>
  212. <view class="btn-confirm" @tap="doConfirm">确认入库</view>
  213. </view>
  214. <view class="draft-bar" v-if="parsing">
  215. <view class="btn-parsing">
  216. <text class="parsing-text">AI 解析中...</text>
  217. </view>
  218. </view>
  219. <view class="draft-bar" v-if="confirming">
  220. <view class="btn-parsing">
  221. <text class="parsing-text">确认中...</text>
  222. </view>
  223. </view>
  224. </view>
  225. </template>
  226. <script>
  227. import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList } from '../../utils/api.js'
  228. import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
  229. import FeedbackPopup from '../../components/feedback-popup.vue'
  230. import { parseDate } from '../../utils/format.js'
  231. var INDICATOR_TYPE_MAP = {
  232. '维生素': 'vitamin',
  233. '氨基酸': 'amino_acid',
  234. '微量元素': 'indicator',
  235. '肠道屏障与代谢物': 'indicator',
  236. '短链脂肪酸': 'indicator',
  237. '神经递质与激素': 'indicator',
  238. '抗生素风险评估': 'indicator'
  239. }
  240. export default {
  241. components: { HealthKnowledgePopup, FeedbackPopup },
  242. data() {
  243. return {
  244. reportId: null,
  245. reportData: null,
  246. report: {},
  247. reportInterpretation: null,
  248. isEditing: false,
  249. accordionOpen: { gut: false, nutri: false },
  250. scoreItems: [],
  251. allIndicators: [],
  252. gutIndicators: [],
  253. nutritionGroups: [],
  254. floraCount: 0,
  255. floraAbnormalCount: 0,
  256. riskCount: 0,
  257. riskAttentionCount: 0,
  258. foodCount: 0,
  259. cautionCount: 0,
  260. indicatorCount: 0,
  261. pathogenCount: 0,
  262. knowledgeVisible: false,
  263. knowledgeData: {},
  264. feedbackVisible: false,
  265. indStatuses: ['正常', '偏低', '偏高', '缺乏', '不足', '过多', '异常'],
  266. analysisData: null,
  267. // draft 模式字段
  268. draftId: null,
  269. isDraftMode: false,
  270. payload: null,
  271. confirming: false,
  272. parsing: false
  273. }
  274. },
  275. computed: {
  276. gutStats: function() {
  277. var list = this.gutIndicators || []
  278. var valid = 0, abnormal = 0
  279. for (var i = 0; i < list.length; i++) {
  280. var item = list[i]
  281. if (item.indicatorValue && String(item.indicatorValue).trim() !== '') valid++
  282. if (this.isAbnormalStatus(item.status)) abnormal++
  283. }
  284. return { total: list.length, valid: valid, abnormal: abnormal }
  285. },
  286. nutriStats: function() {
  287. var list = [], groups = this.nutritionGroups || []
  288. for (var g = 0; g < groups.length; g++) {
  289. var items = groups[g].items
  290. if (items) for (var i = 0; i < items.length; i++) list.push(items[i])
  291. }
  292. var valid = 0, abnormal = 0
  293. for (var i = 0; i < list.length; i++) {
  294. var item = list[i]
  295. if (item.indicatorValue && String(item.indicatorValue).trim() !== '') valid++
  296. if (this.isAbnormalStatus(item.status)) abnormal++
  297. }
  298. return { total: list.length, valid: valid, abnormal: abnormal }
  299. }
  300. },
  301. onLoad: function(options) {
  302. this.reportId = options.reportId ? parseInt(options.reportId) : null
  303. this.draftId = options.draftId ? parseInt(options.draftId) : null
  304. if (this.draftId) {
  305. this.isDraftMode = true
  306. this.loadDraftData()
  307. } else if (this.reportId) {
  308. this.loadData()
  309. }
  310. },
  311. methods: {
  312. goBack: function() { uni.navigateBack() },
  313. /** 报告日期仅显示 yyyy-MM-dd(去掉时间部分) */
  314. reportDateText: function() {
  315. var d = this.report.reportDate
  316. if (!d) return ''
  317. var date = parseDate(d)
  318. if (!date) return String(d).substring(0, 10)
  319. var y = date.getFullYear()
  320. var m = ('' + (date.getMonth() + 1)).padStart(2, '0')
  321. var day = ('' + date.getDate()).padStart(2, '0')
  322. return y + '-' + m + '-' + day
  323. },
  324. /** 状态 → CSS安全英文(兼容中英文,与 report-confirm 保持一致) */
  325. _cssForStatus: function(s) {
  326. var k = String(s || '').trim()
  327. var map = { 'high': 'high', 'low': 'low', 'abnormal': 'abnormal', 'normal': 'normal', '缺乏': 'deficient', '偏低': 'low', '不足': 'insufficient', '正常': 'normal', '注意': 'warning', '偏高': 'high', '异常': 'abnormal', '过多': 'excess', '高风险': 'high-risk', '低风险': 'low-risk', '未检出': 'not-detected' }
  328. return map[k] || 'normal'
  329. },
  330. // 与 _cssForStatus 保持同一白名单:仅明确异常/需关注的状态计入异常数,未知状态视为正常,避免“样式正常却计入异常”
  331. isAbnormalStatus: function(status) {
  332. if (!status) return false
  333. var k = String(status).trim()
  334. var abnormalMap = { 'high': 1, 'low': 1, 'abnormal': 1, '偏高': 1, '偏低': 1, '异常': 1, '缺乏': 1, '不足': 1, '注意': 1, '高风险': 1, '过多': 1 }
  335. return !!abnormalMap[k]
  336. },
  337. statusKey: function(s) {
  338. var map = { '良好': 'good', '注意': 'warning', '需关注': 'danger', '关注': 'danger', '正常': 'normal', '偏高': 'high', '偏低': 'low' }
  339. return map[s] || 'normal'
  340. },
  341. loadData: function() {
  342. var self = this
  343. getReportDetail(this.reportId).then(function(res) {
  344. if (res.code === 200 && res.data) {
  345. self.reportData = res.data
  346. self.report = res.data.report || res.data
  347. self.reportInterpretation = res.data.reportInterpretation || null
  348. self.buildScores(res.data)
  349. self.buildIndicators(res.data)
  350. self.floraCount = (res.data.gutFlora || []).length
  351. // 菌群详情:统计异常/需关注的数量(白名单,与手风琴异常口径一致)
  352. var flora = res.data.gutFlora || []
  353. self.floraAbnormalCount = flora.filter(function(i) {
  354. return self.isAbnormalStatus(i.status)
  355. }).length
  356. self.riskCount = (res.data.diseaseRisks || []).length
  357. // 疾病风险:统计需关注的数量(风险等级非"低风险"即视为需关注)
  358. var risks = res.data.diseaseRisks || []
  359. self.riskAttentionCount = risks.filter(function(r) {
  360. return r.riskLevel && r.riskLevel !== '低风险'
  361. }).length
  362. // 饮食推荐:共用 /api/health/foods 接口获取数量
  363. self.foodCount = 0
  364. getHealthFoods(self.reportId).then(function(foodRes) {
  365. if (foodRes && foodRes.code === 200 && foodRes.data) {
  366. self.foodCount = (Array.isArray(foodRes.data) ? foodRes.data : []).length
  367. }
  368. }).catch(function() {})
  369. // 异步加载戒备食材数量
  370. var userId = uni.getStorageSync('userId')
  371. if (userId) {
  372. getFoodCautionList().then(function(cautionRes) {
  373. if (cautionRes && cautionRes.code === 200) {
  374. self.cautionCount = (cautionRes.data || []).length
  375. }
  376. }).catch(function() {})
  377. }
  378. self.indicatorCount = (res.data.indicators || []).length
  379. // 计算病原菌总数
  380. var flora = res.data.gutFlora || []
  381. self.pathogenCount = flora.filter(function(i) {
  382. return i.category === 'pathogen_genus' || i.category === 'pathogen_detection'
  383. }).length
  384. }
  385. })
  386. // 加载菌群功能分析
  387. this.loadAnalysis()
  388. },
  389. loadAnalysis: function() {
  390. var self = this
  391. getGutFloraAnalysis(this.reportId).then(function(res) {
  392. if (res.code === 200 && res.data && !res.data.error) {
  393. self.analysisData = res.data
  394. }
  395. }).catch(function() {})
  396. },
  397. loadDraftData: function() {
  398. var self = this
  399. self.parsing = true
  400. parseReportDraft(self.draftId).then(function(res) {
  401. self.parsing = false
  402. if (res.code !== 200 || !res.data) {
  403. uni.showToast({ title: res.message || '加载失败', icon: 'none' })
  404. return
  405. }
  406. self.payload = res.data.payload || {}
  407. self.isDraftMode = true
  408. self.applyDraftPayload(self.payload)
  409. }).catch(function() {
  410. self.parsing = false
  411. uni.showToast({ title: '加载异常', icon: 'none' })
  412. })
  413. },
  414. applyDraftPayload: function(payload) {
  415. // 将 draft payload 映射到模板所需的数据结构(report/scoreItems/indicators)
  416. var report = {}
  417. if (payload.summary) {
  418. var s = payload.summary
  419. report.personName = s.personName || ''
  420. report.reportNumber = s.reportNumber || ''
  421. report.gutAge = s.gutAge || ''
  422. report.gutType = s.gutType || ''
  423. report.shannonIndex = s.shannonIndex || ''
  424. report.overallScore = s.overallScore
  425. report.gutHealthScore = s.gutHealthScore
  426. report.chronicDiseaseScore = s.chronicDiseaseScore
  427. report.nutritionScore = s.nutritionScore
  428. report.balanceScore = s.balanceScore
  429. report.diversityScore = s.diversityScore
  430. report.beneficialScore = s.beneficialScore
  431. report.harmfulScore = s.harmfulScore
  432. report.coreGenusScore = s.coreGenusScore
  433. }
  434. this.report = report
  435. this.buildScores({ report: report })
  436. this.buildDraftIndicators(payload.indicators || [])
  437. },
  438. buildDraftIndicators: function(indicators) {
  439. // 复用 buildIndicators 的分组逻辑,输入为 payload.indicators
  440. var all = []
  441. for (var i = 0; i < indicators.length; i++) {
  442. var ind = indicators[i]
  443. var item = {
  444. indicatorName: ind.indicatorName || ind.name || '',
  445. indicatorValue: ind.indicatorValue || ind.value || '',
  446. status: ind.status || '正常',
  447. refRange: ind.refRange || '',
  448. category: ind.category || '其他'
  449. }
  450. item._cssClass = this._cssForStatus(item.status)
  451. all.push(item)
  452. }
  453. this.allIndicators = all
  454. this.indicatorCount = all.length
  455. this._groupIndicators(all)
  456. },
  457. _groupIndicators: function(all) {
  458. var self = this
  459. var gut = []
  460. var nutriGroups = []
  461. var nutriMap = {}
  462. for (var i = 0; i < all.length; i++) {
  463. var ind = all[i]
  464. var g = ind.category
  465. var isGutBarrier = g === '肠道屏障与代谢物' || g === '短链脂肪酸' ||
  466. g === '神经递质与激素' || g === '抗生素风险评估'
  467. if (isGutBarrier) {
  468. gut.push(ind)
  469. } else {
  470. if (!nutriMap[g]) nutriMap[g] = []
  471. nutriMap[g].push(ind)
  472. }
  473. }
  474. this.gutIndicators = gut
  475. for (var key in nutriMap) {
  476. nutriGroups.push({ category: key, items: nutriMap[key] })
  477. }
  478. this.nutritionGroups = nutriGroups
  479. },
  480. buildScores: function(data) {
  481. // 兼容两套字段名:实体存 balanceScore/diversityScore 等,旧视图读 gutBalanceScore 等
  482. var r = data.report || data
  483. var s = r.summary || r
  484. var items = []
  485. var scoreFields = [
  486. { key: 'overallScore', label: '综合', color: '#4A9BD7' },
  487. { key: 'gutHealthScore', label: '菌群健康', color: '#10B981' },
  488. { key: 'chronicDiseaseScore', label: '慢病控制', color: '#F59E0B' },
  489. { key: 'nutritionScore', label: '营养均衡', color: '#FF8C42' },
  490. { key: 'balanceScore', label: '平衡', color: '#6366F1' },
  491. { key: 'diversityScore', label: '多样性', color: '#FF6B9D' },
  492. { key: 'beneficialScore', label: '有益菌', color: '#10B981' },
  493. { key: 'harmfulScore', label: '有害菌', color: '#C62828' },
  494. { key: 'coreGenusScore', label: '核心菌属', color: '#4A9BD7' }
  495. ]
  496. for (var i = 0; i < scoreFields.length; i++) {
  497. var f = scoreFields[i]
  498. var val = s[f.key] != null ? s[f.key] : r[f.key]
  499. if (val != null) {
  500. items.push({ value: val, label: f.label, color: f.color })
  501. }
  502. }
  503. this.scoreItems = items
  504. },
  505. buildIndicators: function(data) {
  506. var indicators = data.indicators || []
  507. // 为每个指标添加_cssClass字段(wxss不支持中文类名)
  508. var self = this
  509. indicators.forEach(function(item) {
  510. item._cssClass = self._cssForStatus(item.status)
  511. })
  512. this.allIndicators = indicators
  513. var gutCategories = ['肠道屏障与代谢物', '短链脂肪酸', '神经递质与激素', '抗生素风险评估']
  514. this.gutIndicators = indicators.filter(function(item) {
  515. return gutCategories.indexOf(item.category) >= 0
  516. })
  517. var seen = {}
  518. var groups = []
  519. for (var i = 0; i < indicators.length; i++) {
  520. var item = indicators[i]
  521. if (gutCategories.indexOf(item.category) >= 0) continue
  522. if (!seen[item.category]) {
  523. seen[item.category] = true
  524. groups.push({ category: item.category, items: [] })
  525. }
  526. for (var g = 0; g < groups.length; g++) {
  527. if (groups[g].category === item.category) {
  528. groups[g].items.push(item)
  529. break
  530. }
  531. }
  532. }
  533. this.nutritionGroups = groups
  534. },
  535. toggleAccordion: function(key) {
  536. this.accordionOpen[key] = !this.accordionOpen[key]
  537. },
  538. indicatorType: function(item) {
  539. return INDICATOR_TYPE_MAP[item.category] || 'nutrient'
  540. },
  541. indStatusIndex: function(status) {
  542. var idx = this.indStatuses.indexOf(status)
  543. return idx >= 0 ? idx : 0
  544. },
  545. onIndStatusChange: function(e, item) {
  546. item.status = this.indStatuses[e.detail.value]
  547. },
  548. showKnowledge: function(type, name) {
  549. var self = this
  550. // 优先使用 KB v3 查询指标调整建议
  551. queryIndicatorKnowledge(name).then(function(res) {
  552. if (res.code === 200 && res.data) {
  553. var v3 = res.data
  554. var high = v3['偏高影响'] || ''
  555. var low = v3['偏低影响'] || ''
  556. self.knowledgeData = {
  557. itemName: v3['名称'] || name,
  558. category: v3['说明'] || '',
  559. description: (high ? '偏高影响:' + high + '\n' : '') + (low ? '偏低影响:' + low : ''),
  560. suggestion: v3['调整建议'] || ''
  561. }
  562. self.knowledgeVisible = true
  563. } else {
  564. // 回退旧版 DB 查询
  565. self.queryOldKnowledgeBase(type, name)
  566. }
  567. }).catch(function() {
  568. self.queryOldKnowledgeBase(type, name)
  569. })
  570. },
  571. queryOldKnowledgeBase: function(type, name) {
  572. var self = this
  573. queryKnowledgeBase(type, name).then(function(res) {
  574. if (res.code === 200 && res.data) {
  575. self.knowledgeData = res.data
  576. self.knowledgeVisible = true
  577. } else {
  578. self.knowledgeData = { itemName: name }
  579. self.knowledgeVisible = true
  580. }
  581. })
  582. },
  583. closeKnowledge: function() {
  584. this.knowledgeVisible = false
  585. this.knowledgeData = {}
  586. },
  587. openFeedback: function() {
  588. this.feedbackVisible = true
  589. },
  590. closeFeedback: function() {
  591. this.feedbackVisible = false
  592. },
  593. toggleEdit: function() {
  594. this.isEditing = true
  595. },
  596. cancelEdit: function() {
  597. this.isEditing = false
  598. this.loadData()
  599. },
  600. saveEdit: function() {
  601. var self = this
  602. var payload = {
  603. summary: {
  604. overallScore: this.report.overallScore,
  605. gutHealthScore: this.report.gutHealthScore,
  606. chronicDiseaseScore: this.report.chronicDiseaseScore,
  607. nutritionScore: this.report.nutritionScore,
  608. balanceScore: this.report.balanceScore,
  609. diversityScore: this.report.diversityScore,
  610. beneficialScore: this.report.beneficialScore,
  611. harmfulScore: this.report.harmfulScore,
  612. coreGenusScore: this.report.coreGenusScore
  613. },
  614. indicators: this.allIndicators
  615. }
  616. editHealthReport(this.reportId, payload, 0).then(function(res) {
  617. if (res.code === 200) {
  618. uni.showToast({ title: '已保存', icon: 'success' })
  619. self.isEditing = false
  620. self.loadData()
  621. } else {
  622. uni.showToast({ title: res.message || '保存失败', icon: 'none' })
  623. }
  624. })
  625. },
  626. doConfirm: function() {
  627. var self = this
  628. if (!self.draftId) return
  629. self.confirming = true
  630. confirmReportPreview({ draftId: self.draftId, payload: self.payload }).then(function(res) {
  631. self.confirming = false
  632. if (res.code === 200) {
  633. var reportId = res.data && res.data.reportId
  634. var planId = res.data && res.data.planId
  635. var goDetail = function() {
  636. if (reportId) {
  637. uni.redirectTo({ url: '/pages/health/gut-flora-detail?reportId=' + reportId })
  638. } else {
  639. uni.navigateBack()
  640. }
  641. }
  642. if (planId) {
  643. uni.showModal({
  644. title: '报告已入库',
  645. content: '健康方案已自动生成,是否立即查看并确认任务?',
  646. confirmText: '查看方案',
  647. cancelText: '看报告',
  648. success: function(modalRes) {
  649. if (modalRes.confirm) {
  650. uni.redirectTo({ url: '/pages/health/health-plan-summary?planId=' + planId })
  651. } else {
  652. goDetail()
  653. }
  654. }
  655. })
  656. } else {
  657. goDetail()
  658. }
  659. } else {
  660. uni.showToast({ title: res.message || '确认失败', icon: 'none' })
  661. }
  662. }).catch(function() {
  663. self.confirming = false
  664. uni.showToast({ title: '确认异常', icon: 'none' })
  665. })
  666. },
  667. doDiscard: function() {
  668. var self = this
  669. if (!self.draftId) return
  670. uni.showModal({
  671. title: '提示',
  672. content: '确定放弃该报告草稿吗?',
  673. success: function(res) {
  674. if (!res.confirm) return
  675. discardReportDraft(self.draftId).then(function(res2) {
  676. if (res2.code === 200) {
  677. uni.navigateBack()
  678. } else {
  679. uni.showToast({ title: res2.message || '操作失败', icon: 'none' })
  680. }
  681. })
  682. }
  683. })
  684. },
  685. goToSpecies: function() {
  686. uni.navigateTo({ url: '/pages/health/gut-flora-species-detail?reportId=' + this.reportId })
  687. },
  688. goToPathogen: function() {
  689. uni.navigateTo({ url: '/pages/health/gut-flora-species-detail?reportId=' + this.reportId + '&tab=pathogen_genus' })
  690. },
  691. goToTaxonomy: function() {
  692. uni.navigateTo({ url: '/pages/health/gut-flora-species-detail?reportId=' + this.reportId + '&tab=taxonomy_class' })
  693. },
  694. goToRisks: function() {
  695. uni.navigateTo({ url: '/pages/health/gut-flora-risks-detail?reportId=' + this.reportId })
  696. },
  697. goToFoods: function() {
  698. uni.navigateTo({ url: '/pages/health/gut-flora-foods-detail?reportId=' + this.reportId })
  699. },
  700. goToCaution: function() {
  701. uni.navigateTo({ url: '/pages/growth/caution-detail' })
  702. },
  703. goToIndicators: function() {
  704. this.accordionOpen.nutri = true
  705. },
  706. // 问答功能已屏蔽
  707. goAIChat: function() {}
  708. }
  709. }
  710. </script>
  711. <style scoped>
  712. .page { min-height: 100vh; background: #f8f8f8; }
  713. .content { height: calc(100vh - 100rpx); }
  714. .report-header { background: linear-gradient(135deg, #4A9BD7, #6366F1); padding: 40rpx 30rpx; color: #fff; }
  715. .report-title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 16rpx; }
  716. .report-meta { display: flex; gap: 20rpx; margin-bottom: 6rpx; }
  717. .meta-item { font-size: 24rpx; opacity: 0.9; }
  718. .badge-row { display: flex; gap: 16rpx; margin-top: 12rpx; }
  719. .badge { padding: 6rpx 20rpx; background: rgba(255,255,255,0.2); border-radius: 20rpx; font-size: 22rpx; }
  720. .scores-wrap { display: flex; flex-wrap: wrap; padding: 20rpx 10rpx; background: #fff; }
  721. .score-item { display: flex; flex-direction: column; align-items: center; margin: 12rpx 10rpx; width: calc(25% - 20rpx); }
  722. .score-ring { width: 100rpx; height: 100rpx; border-radius: 50%; border: 6rpx solid; display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 700; margin-bottom: 8rpx; }
  723. .score-label { font-size: 22rpx; color: #666; }
  724. .quick-nav { display: flex; padding: 20rpx; background: #fff; margin-bottom: 16rpx; }
  725. .nav-card { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 16rpx 0; }
  726. .caution-card {
  727. background: #FEF2F2;
  728. border-radius: 16rpx;
  729. border: 2rpx solid #FECACA;
  730. }
  731. .caution-count { color: #DC2626; font-weight: 700; }
  732. .nav-icon { font-size: 40rpx; margin-bottom: 6rpx; }
  733. .nav-label { font-size: 24rpx; color: #333; }
  734. .nav-count { font-size: 20rpx; color: #999; }
  735. .accordion-section { background: #fff; margin-bottom: 16rpx; }
  736. .accordion-header { display: flex; justify-content: space-between; padding: 30rpx; border-bottom: 1rpx solid #f0f0f0; }
  737. .accordion-title { font-size: 30rpx; font-weight: 600; color: #333; }
  738. .accordion-stats { font-size: 22rpx; color: #999; margin-left: 16rpx; }
  739. .accordion-arrow { font-size: 24rpx; color: #999; }
  740. .accordion-body { padding: 10rpx 30rpx 20rpx; }
  741. .sub-group { margin-bottom: 16rpx; }
  742. .sub-group-title { font-size: 26rpx; color: #4A9BD7; font-weight: 600; display: block; margin: 12rpx 0 8rpx; }
  743. .indicator-card { padding: 16rpx 0; border-bottom: 1rpx solid #f5f5f5; }
  744. .indicator-row { display: flex; align-items: center; justify-content: space-between; }
  745. .indicator-name { font-size: 28rpx; color: #333; flex: 1; }
  746. .arrow-up { color: #C62828; font-size: 22rpx; }
  747. .arrow-down { color: #E65100; font-size: 22rpx; }
  748. .arrow-warn { color: #F59E0B; font-size: 22rpx; }
  749. .val-text { font-size: 28rpx; font-weight: 600; color: #333; }
  750. .val-high { color: #C62828; }
  751. .val-excess { color: #C62828; }
  752. .val-low { color: #C62828; }
  753. .val-deficient { color: #C62828; }
  754. .val-insufficient { color: #C62828; }
  755. .val-abnormal { color: #C62828; }
  756. .val-warning { color: #F59E0B; }
  757. .indicator-range { font-size: 22rpx; color: #bbb; display: block; margin-top: 4rpx; }
  758. /* 指标知识库 "?" 按钮(与通用报告页一致) */
  759. .ind-help-btn { display: inline-flex; align-items: center; justify-content: center; width: 36rpx; height: 36rpx; border-radius: 50%; background: #E3F2FD; color: #1976D2; font-size: 22rpx; font-weight: 600; margin-left: 8rpx; flex-shrink: 0; }
  760. /* 综合评级行 */
  761. .rating-line { margin: 12rpx 0 0; }
  762. .rating-text { font-size: 24rpx; color: #666; display: block; }
  763. /* 指标单位与症状 */
  764. .indicator-unit { font-size: 22rpx; color: #999; margin-left: 6rpx; }
  765. .indicator-symptoms { font-size: 22rpx; color: #E65100; display: block; margin-top: 4rpx; }
  766. .indicator-edit { display: flex; gap: 8rpx; align-items: center; }
  767. .edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 6rpx 10rpx; font-size: 26rpx; width: 100rpx; }
  768. .edit-picker { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 6rpx 10rpx; font-size: 24rpx; color: #333; }
  769. .fab { position: fixed; right: 30rpx; bottom: 40rpx; width: 100rpx; height: 100rpx; background: #4A9BD7; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 16rpx rgba(74,155,215,0.4); }
  770. .fab-icon { font-size: 40rpx; color: #fff; }
  771. .draft-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
  772. .btn-discard { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
  773. .btn-confirm { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
  774. .btn-parsing { flex: 1; padding: 20rpx; background: #f0f0f0; border-radius: 12rpx; text-align: center; }
  775. .parsing-text { font-size: 28rpx; color: #999; }
  776. .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
  777. .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
  778. .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
  779. .feedback-section {
  780. display: flex;
  781. align-items: center;
  782. padding: 24rpx 30rpx;
  783. background: #fff;
  784. margin: 16rpx;
  785. border-radius: 12rpx;
  786. border: 1rpx solid #f0f0f0;
  787. }
  788. .feedback-icon { font-size: 32rpx; margin-right: 12rpx; }
  789. .feedback-text { flex: 1; font-size: 26rpx; color: #4A9BD7; }
  790. .feedback-arrow { font-size: 28rpx; color: #ccc; }
  791. .bottom-spacer { height: 40rpx; }
  792. /* 报告解读 */
  793. .interpretation-box {
  794. background: #FFF8E1;
  795. border-radius: 12rpx;
  796. padding: 16rpx 20rpx;
  797. margin: 16rpx 0 0;
  798. border-left: 6rpx solid #FFB300;
  799. }
  800. .interp-text {
  801. font-size: 24rpx;
  802. color: #795548;
  803. line-height: 1.6;
  804. }
  805. /* 解读入口 */
  806. .ai-fab {
  807. position: fixed;
  808. right: 30rpx;
  809. bottom: 100rpx;
  810. display: flex;
  811. flex-direction: row;
  812. align-items: center;
  813. background: linear-gradient(135deg, #6366F1, #8B5CF6);
  814. border-radius: 40rpx;
  815. padding: 16rpx 28rpx;
  816. box-shadow: 0 4rpx 16rpx rgba(99, 102, 241, 0.4);
  817. z-index: 100;
  818. }
  819. .ai-fab .fab-icon { font-size: 32rpx; margin-right: 12rpx; }
  820. .ai-fab .fab-text { font-size: 26rpx; color: #fff; font-weight: 500; }
  821. /* ===== 菌群功能分析 ===== */
  822. .analysis-section {
  823. margin: 20rpx 30rpx;
  824. }
  825. .section-title-bar {
  826. margin-bottom: 16rpx;
  827. }
  828. .analysis-title {
  829. font-size: 30rpx;
  830. font-weight: bold;
  831. color: #333;
  832. display: block;
  833. }
  834. .analysis-subtitle {
  835. font-size: 22rpx;
  836. color: #999;
  837. display: block;
  838. margin-top: 4rpx;
  839. }
  840. .analysis-card {
  841. background: #fff;
  842. border-radius: 16rpx;
  843. padding: 24rpx;
  844. margin-bottom: 16rpx;
  845. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
  846. }
  847. .card-label {
  848. font-size: 26rpx;
  849. font-weight: 600;
  850. color: #333;
  851. display: block;
  852. margin-bottom: 16rpx;
  853. }
  854. .func-dim {
  855. margin-bottom: 20rpx;
  856. }
  857. .func-dim:last-child { margin-bottom: 0; }
  858. .func-dim-head {
  859. display: flex;
  860. justify-content: space-between;
  861. align-items: center;
  862. margin-bottom: 8rpx;
  863. }
  864. .func-dim-name {
  865. font-size: 24rpx;
  866. color: #333;
  867. font-weight: 500;
  868. }
  869. .func-badge {
  870. font-size: 20rpx;
  871. padding: 2rpx 14rpx;
  872. border-radius: 20rpx;
  873. color: #fff;
  874. }
  875. .func-good { background: #10B981; }
  876. .func-warning { background: #F59E0B; }
  877. .func-danger { background: #EF4444; }
  878. .func-normal { background: #6B7280; }
  879. .func-high { background: #EF4444; }
  880. .func-low { background: #F59E0B; }
  881. .func-bar {
  882. height: 8rpx;
  883. background: #f0f0f0;
  884. border-radius: 4rpx;
  885. overflow: hidden;
  886. margin-bottom: 6rpx;
  887. }
  888. .func-bar-fill {
  889. height: 100%;
  890. border-radius: 4rpx;
  891. transition: width 0.5s ease;
  892. }
  893. .fill-good { background: #10B981; }
  894. .fill-warning { background: #F59E0B; }
  895. .fill-danger { background: #EF4444; }
  896. .fill-normal { background: #6B7280; }
  897. .func-dim-summary {
  898. font-size: 22rpx;
  899. color: #888;
  900. line-height: 1.5;
  901. }
  902. .pb-row {
  903. display: flex;
  904. gap: 20rpx;
  905. align-items: center;
  906. margin-bottom: 8rpx;
  907. }
  908. .pb-label {
  909. font-size: 24rpx;
  910. color: #666;
  911. }
  912. .pb-ratio {
  913. font-size: 24rpx;
  914. color: #6366F1;
  915. font-weight: bold;
  916. }
  917. .pattern-name {
  918. font-size: 28rpx;
  919. font-weight: bold;
  920. color: #FF8C42;
  921. display: block;
  922. margin-bottom: 8rpx;
  923. }
  924. .risk-item {
  925. display: flex;
  926. flex-wrap: wrap;
  927. align-items: center;
  928. gap: 12rpx;
  929. padding: 12rpx 0;
  930. border-bottom: 1rpx solid #f0f0f0;
  931. }
  932. .risk-item:last-child { border-bottom: none; }
  933. .risk-name {
  934. font-size: 24rpx;
  935. color: #333;
  936. font-weight: 500;
  937. }
  938. .risk-level {
  939. font-size: 20rpx;
  940. padding: 2rpx 14rpx;
  941. border-radius: 20rpx;
  942. color: #fff;
  943. }
  944. .risk-normal { background: #10B981; }
  945. .risk-warning { background: #F59E0B; }
  946. .risk-danger { background: #EF4444; }
  947. .risk-bacteria {
  948. font-size: 22rpx;
  949. color: #999;
  950. width: 100%;
  951. margin-top: 4rpx;
  952. }
  953. .probiotic-item {
  954. display: flex;
  955. flex-wrap: wrap;
  956. align-items: center;
  957. gap: 12rpx;
  958. padding: 12rpx 0;
  959. border-bottom: 1rpx solid #f0f0f0;
  960. }
  961. .probiotic-item:last-child { border-bottom: none; }
  962. .probiotic-name {
  963. font-size: 24rpx;
  964. color: #333;
  965. font-weight: 500;
  966. width: 120rpx;
  967. }
  968. .probiotic-advice {
  969. font-size: 22rpx;
  970. color: #888;
  971. width: 100%;
  972. margin-top: 4rpx;
  973. line-height: 1.5;
  974. }
  975. </style>