nutrition-profile.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. <template>
  2. <view class="nutrition-profile">
  3. <view class="profile-header" v-if="childName">
  4. <view class="child-info">
  5. <text class="child-name">{{ childName }}</text>
  6. <text class="child-age" v-if="childAge">{{ childAge }}岁</text>
  7. </view>
  8. </view>
  9. <view class="score-section" v-if="scores">
  10. <view class="main-score-ring">
  11. <view class="score-ring-outer">
  12. <view class="score-ring-inner">
  13. <text class="score-value">{{ scores.overall || '--' }}</text>
  14. <text class="score-label">综合评分</text>
  15. </view>
  16. </view>
  17. </view>
  18. <view class="score-items">
  19. <view class="score-item">
  20. <text class="score-num">{{ scores.gutHealth || '--' }}</text>
  21. <text class="score-name">肠道健康</text>
  22. </view>
  23. <view class="score-item">
  24. <text class="score-num">{{ scores.nutrition || '--' }}</text>
  25. <text class="score-name">营养状况</text>
  26. </view>
  27. <view class="score-item">
  28. <text class="score-num">{{ scores.chronicDisease || '--' }}</text>
  29. <text class="score-name">慢病控制</text>
  30. </view>
  31. <view class="score-item">
  32. <text class="score-num">{{ scores.gutBalance || '--' }}</text>
  33. <text class="score-name">菌群平衡</text>
  34. </view>
  35. </view>
  36. </view>
  37. <view class="empty-state" v-if="!scores && !loading">
  38. <text class="empty-icon">📋</text>
  39. <text class="empty-title">暂无健康报告</text>
  40. <text class="empty-desc">完成首次健康检测后,这里将展示您的营养档案</text>
  41. <view class="empty-btn" @click="goToAssessment">
  42. <text class="empty-btn-text">预约健康检测</text>
  43. </view>
  44. </view>
  45. <view class="section" v-if="abnormalIndicators && abnormalIndicators.length > 0">
  46. <view class="section-title-wrap">
  47. <text class="section-title">⚠️ 需关注指标</text>
  48. <text class="section-count">{{ abnormalIndicators.length }}项</text>
  49. </view>
  50. <view class="indicator-list">
  51. <view class="indicator-item" v-for="item in abnormalIndicators" :key="item.id">
  52. <view class="indicator-left">
  53. <text class="indicator-emoji">{{ getStatusEmoji(item.status) }}</text>
  54. <view class="indicator-info">
  55. <text class="indicator-name">{{ item.indicatorName }}</text>
  56. <text class="indicator-value">{{ item.indicatorValue }}{{ item.unit }} · {{ item.status }}</text>
  57. </view>
  58. </view>
  59. </view>
  60. </view>
  61. <view class="section-more" @click="goToIndicators">
  62. <text class="section-more-text">查看全部指标 →</text>
  63. </view>
  64. </view>
  65. <view class="section" v-if="scoreTrend && scoreTrend.length > 1">
  66. <view class="section-title-wrap">
  67. <text class="section-title">📊 变化趋势</text>
  68. </view>
  69. <view class="trend-chart">
  70. <canvas canvas-id="trendCanvas" id="trendCanvas" class="trend-canvas" @touchstart.prevent></canvas>
  71. </view>
  72. <view class="trend-legend">
  73. <view class="legend-item">
  74. <view class="legend-color" style="background:#F97316"></view>
  75. <text class="legend-text">综合评分</text>
  76. </view>
  77. <view class="legend-item">
  78. <view class="legend-color" style="background:#10B981"></view>
  79. <text class="legend-text">肠道健康</text>
  80. </view>
  81. </view>
  82. </view>
  83. <view class="section" v-if="deficiencyRecords && deficiencyRecords.length > 0">
  84. <view class="section-title-wrap">
  85. <text class="section-title">🔬 营养缺乏分析</text>
  86. <text class="section-count">{{ deficiencyRecords.length }}项</text>
  87. </view>
  88. <view class="deficiency-list">
  89. <view class="deficiency-item" v-for="record in deficiencyRecords" :key="record.nutrient">
  90. <view class="deficiency-info">
  91. <text class="deficiency-name">{{ record.nutrient }}</text>
  92. <text class="deficiency-level">{{ getDeficiencyLabel(record) }}</text>
  93. </view>
  94. <view class="deficiency-bar">
  95. <view class="deficiency-fill" :style="{ width: (record.severity * 100) + '%', background: getSeverityColor(record.severity) }"></view>
  96. </view>
  97. <view class="deficiency-foods" v-if="record.foodSuggestions">
  98. <text>推荐食物:{{ record.foodSuggestions }}</text>
  99. </view>
  100. </view>
  101. </view>
  102. </view>
  103. <view class="section" v-if="latestReports && latestReports.length > 0">
  104. <view class="section-title-wrap">
  105. <text class="section-title">📋 历史报告</text>
  106. </view>
  107. <view class="report-list">
  108. <view class="report-item" v-for="r in latestReports" :key="r.id" @click="goToReport(r.id)">
  109. <view class="report-item-left">
  110. <text class="report-item-type">{{ getReportTypeName(r.reportType) }}</text>
  111. <text class="report-item-date">{{ formatDate(r.reportDate) }}</text>
  112. </view>
  113. <view class="report-item-score">
  114. <text class="report-score-val">{{ r.overallScore || '--' }}</text>
  115. <text class="report-score-label">分</text>
  116. </view>
  117. </view>
  118. </view>
  119. </view>
  120. <view class="bottom-spacer"></view>
  121. <view class="fab" @click="goToAIRecommend" v-if="scores">
  122. <text class="fab-icon">🥗</text>
  123. <text class="fab-text">获取营养方案</text>
  124. </view>
  125. </view>
  126. </template>
  127. <script>
  128. import { getChildNutritionProfile, getDeficiencyAnalysis } from '@/utils/api.js'
  129. import { parseDate } from '@/utils/format.js'
  130. export default {
  131. data() {
  132. return {
  133. memberId: null,
  134. childName: '',
  135. childAge: '',
  136. childGender: '',
  137. scores: null,
  138. abnormalIndicators: [],
  139. scoreTrend: [],
  140. deficiencyRecords: [],
  141. latestReports: [],
  142. loading: false
  143. }
  144. },
  145. onLoad: function(options) {
  146. this.memberId = options.memberId ? parseInt(options.memberId) : null
  147. if (!this.memberId) {
  148. var savedChildId = uni.getStorageSync('currentChildId')
  149. if (savedChildId) this.memberId = parseInt(savedChildId)
  150. }
  151. if (this.memberId) {
  152. this.loadProfile()
  153. }
  154. },
  155. onReady: function() {
  156. if (this.scoreTrend && this.scoreTrend.length > 1) {
  157. this.drawTrendChart()
  158. }
  159. },
  160. methods: {
  161. loadProfile: function() {
  162. var self = this
  163. self.loading = true
  164. uni.showLoading({ title: '加载中...' })
  165. getChildNutritionProfile(self.memberId).then(function(res) {
  166. uni.hideLoading()
  167. self.loading = false
  168. if (res.code === 200 && res.data) {
  169. var d = res.data
  170. self.childName = d.childName || ''
  171. self.childAge = d.childAge || ''
  172. self.childGender = d.childGender || ''
  173. self.scores = d.scores
  174. self.abnormalIndicators = d.abnormalIndicators || []
  175. self.scoreTrend = d.scoreTrend || []
  176. self.latestReports = d.latestReports || []
  177. if (d.nutritionProfile) {
  178. self.nutritionProfile = d.nutritionProfile
  179. }
  180. }
  181. }).catch(function(e) {
  182. uni.hideLoading()
  183. self.loading = false
  184. console.log('加载营养档案失败', e)
  185. })
  186. getDeficiencyAnalysis(self.memberId).then(function(res) {
  187. if (res.code === 200 && res.data) {
  188. self.deficiencyRecords = res.data
  189. }
  190. }).catch(function(e) {
  191. console.log('加载缺乏分析失败', e)
  192. })
  193. },
  194. drawTrendChart: function() {
  195. var ctx = uni.createCanvasContext('trendCanvas', this)
  196. var trend = this.scoreTrend
  197. if (!trend || trend.length < 2) return
  198. var cw = 690
  199. var ch = 300
  200. var padding = 40
  201. var xStep = (cw - padding * 2) / (trend.length - 1)
  202. var yMin = 0
  203. var yMax = 100
  204. ctx.setStrokeStyle('#F0F0F0')
  205. ctx.setLineWidth(1)
  206. for (var i = 0; i <= 4; i++) {
  207. var y = padding + (ch - padding * 2) * (i / 4)
  208. ctx.beginPath()
  209. ctx.moveTo(padding, y)
  210. ctx.lineTo(cw - padding, y)
  211. ctx.stroke()
  212. }
  213. var drawLine = function(key, color) {
  214. ctx.setStrokeStyle(color)
  215. ctx.setLineWidth(3)
  216. ctx.beginPath()
  217. for (var j = 0; j < trend.length; j++) {
  218. var val = trend[j][key] || 0
  219. var x = padding + j * xStep
  220. var y = padding + (ch - padding * 2) * (1 - (val - yMin) / (yMax - yMin))
  221. if (j === 0) {
  222. ctx.moveTo(x, y)
  223. } else {
  224. ctx.lineTo(x, y)
  225. }
  226. }
  227. ctx.stroke()
  228. ctx.draw(true)
  229. }
  230. drawLine('overall', '#F97316')
  231. drawLine('gutHealth', '#10B981')
  232. },
  233. getStatusEmoji: function(status) {
  234. if (status === '偏低') return '🔴'
  235. if (status === '偏高') return '🔴'
  236. if (status === '注意') return '🟡'
  237. return '🟢'
  238. },
  239. getDeficiencyLabel: function(record) {
  240. if (record.deficiencyType === 'deficiency') {
  241. if (record.severity >= 0.7) return '严重缺乏'
  242. if (record.severity >= 0.3) return '缺乏'
  243. return '轻度不足'
  244. }
  245. if (record.deficiencyType === 'excess') return '偏高'
  246. if (record.deficiencyType === 'borderline') return '边界值'
  247. return '未分类'
  248. },
  249. getSeverityColor: function(severity) {
  250. if (severity >= 0.7) return '#EF4444'
  251. if (severity >= 0.3) return '#F59E0B'
  252. return '#10B981'
  253. },
  254. getReportTypeName: function(type) {
  255. if (type === 'gut_flora') return '肠道检测'
  256. if (type === 'physical_exam') return '体检报告'
  257. if (type === 'blood') return '血液检测'
  258. return type || '健康报告'
  259. },
  260. formatDate: function(dateStr) {
  261. if (!dateStr) return ''
  262. var d = parseDate(dateStr)
  263. if (!d) return dateStr
  264. var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
  265. var day = ('' + d.getDate()).padStart(2, '0')
  266. return m + '-' + day
  267. },
  268. goBack: function() {
  269. uni.navigateBack()
  270. },
  271. goToReport: function(id) {
  272. uni.navigateTo({ url: '/pages/body-detail/health-report?reportId=' + id + '&memberId=' + this.memberId })
  273. },
  274. goToIndicators: function() {
  275. uni.navigateTo({ url: '/pages/body-detail/health-report?memberId=' + this.memberId })
  276. },
  277. goToAssessment: function() {
  278. uni.navigateTo({ url: '/pages/assessment/apply' })
  279. },
  280. goToAIRecommend: function() {
  281. uni.navigateTo({ url: '/pages/meal/recommend?memberId=' + this.memberId })
  282. }
  283. }
  284. }
  285. </script>
  286. <style scoped>
  287. .nutrition-profile {
  288. min-height: 100vh;
  289. background: #FFF7ED;
  290. padding-bottom: 40rpx;
  291. }
  292. .nav-placeholder {
  293. width: 80rpx;
  294. }
  295. .profile-header {
  296. padding: 40rpx 30rpx 20rpx;
  297. }
  298. .child-info {
  299. display: flex;
  300. flex-direction: column;
  301. }
  302. .child-name {
  303. font-size: 40rpx;
  304. font-weight: bold;
  305. color: #1F2937;
  306. }
  307. .child-age {
  308. font-size: 26rpx;
  309. color: #6B7280;
  310. margin-top: 8rpx;
  311. }
  312. .score-section {
  313. margin: 0 20rpx;
  314. background: white;
  315. border-radius: 24rpx;
  316. padding: 30rpx;
  317. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
  318. }
  319. .main-score-ring {
  320. display: flex;
  321. justify-content: center;
  322. align-items: center;
  323. margin-bottom: 30rpx;
  324. }
  325. .score-ring-outer {
  326. width: 200rpx;
  327. height: 200rpx;
  328. border-radius: 100rpx;
  329. border: 12rpx solid #F97316;
  330. display: flex;
  331. align-items: center;
  332. justify-content: center;
  333. }
  334. .score-ring-inner {
  335. display: flex;
  336. flex-direction: column;
  337. align-items: center;
  338. }
  339. .score-value {
  340. font-size: 56rpx;
  341. font-weight: bold;
  342. color: #F97316;
  343. }
  344. .score-label {
  345. font-size: 24rpx;
  346. color: #9CA3AF;
  347. }
  348. .score-items {
  349. display: flex;
  350. flex-direction: row;
  351. justify-content: space-around;
  352. flex-wrap: wrap;
  353. }
  354. .score-item {
  355. display: flex;
  356. flex-direction: column;
  357. align-items: center;
  358. width: 25%;
  359. }
  360. .score-num {
  361. font-size: 32rpx;
  362. font-weight: bold;
  363. color: #374151;
  364. }
  365. .score-name {
  366. font-size: 22rpx;
  367. color: #9CA3AF;
  368. margin-top: 4rpx;
  369. }
  370. .empty-state {
  371. display: flex;
  372. flex-direction: column;
  373. align-items: center;
  374. padding: 80rpx 30rpx;
  375. }
  376. .empty-icon {
  377. font-size: 80rpx;
  378. }
  379. .empty-title {
  380. font-size: 36rpx;
  381. font-weight: bold;
  382. color: #374151;
  383. margin-top: 20rpx;
  384. }
  385. .empty-desc {
  386. font-size: 28rpx;
  387. color: #9CA3AF;
  388. text-align: center;
  389. margin: 16rpx 0 30rpx;
  390. }
  391. .empty-btn {
  392. background: #F97316;
  393. border-radius: 40rpx;
  394. padding: 20rpx 60rpx;
  395. }
  396. .empty-btn-text {
  397. color: white;
  398. font-size: 28rpx;
  399. }
  400. .section {
  401. background: white;
  402. border-radius: 24rpx;
  403. margin: 20rpx;
  404. padding: 30rpx;
  405. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
  406. }
  407. .section-title-wrap {
  408. display: flex;
  409. flex-direction: row;
  410. align-items: center;
  411. justify-content: space-between;
  412. margin-bottom: 20rpx;
  413. }
  414. .section-title {
  415. font-size: 32rpx;
  416. font-weight: bold;
  417. color: #374151;
  418. }
  419. .section-count {
  420. font-size: 24rpx;
  421. color: #9CA3AF;
  422. }
  423. .indicator-list {
  424. display: flex;
  425. flex-direction: column;
  426. }
  427. .indicator-item {
  428. display: flex;
  429. flex-direction: row;
  430. align-items: center;
  431. justify-content: space-between;
  432. padding: 12rpx 0;
  433. border-bottom: 1rpx solid #F3F4F6;
  434. }
  435. .indicator-item:last-child {
  436. border-bottom: none;
  437. }
  438. .indicator-left {
  439. display: flex;
  440. flex-direction: row;
  441. align-items: center;
  442. }
  443. .indicator-emoji {
  444. font-size: 28rpx;
  445. margin-right: 16rpx;
  446. }
  447. .indicator-info {
  448. display: flex;
  449. flex-direction: column;
  450. }
  451. .indicator-name {
  452. font-size: 28rpx;
  453. color: #374151;
  454. font-weight: 500;
  455. }
  456. .indicator-value {
  457. font-size: 24rpx;
  458. color: #9CA3AF;
  459. margin-top: 4rpx;
  460. }
  461. .section-more {
  462. text-align: center;
  463. padding-top: 20rpx;
  464. }
  465. .section-more-text {
  466. color: #F97316;
  467. font-size: 26rpx;
  468. }
  469. .trend-canvas {
  470. width: 100%;
  471. height: 300rpx;
  472. }
  473. .trend-legend {
  474. display: flex;
  475. flex-direction: row;
  476. justify-content: center;
  477. margin-top: 16rpx;
  478. }
  479. .legend-item {
  480. display: flex;
  481. flex-direction: row;
  482. align-items: center;
  483. margin-left: 30rpx;
  484. }
  485. .legend-item:first-child {
  486. margin-left: 0;
  487. }
  488. .legend-color {
  489. width: 20rpx;
  490. height: 20rpx;
  491. border-radius: 4rpx;
  492. margin-right: 8rpx;
  493. }
  494. .legend-text {
  495. font-size: 24rpx;
  496. color: #6B7280;
  497. }
  498. .deficiency-list {
  499. display: flex;
  500. flex-direction: column;
  501. }
  502. .deficiency-item {
  503. display: flex;
  504. flex-direction: column;
  505. padding: 16rpx 0;
  506. border-bottom: 1rpx solid #F3F4F6;
  507. }
  508. .deficiency-item:last-child {
  509. border-bottom: none;
  510. }
  511. .deficiency-info {
  512. display: flex;
  513. flex-direction: row;
  514. justify-content: space-between;
  515. align-items: center;
  516. margin-bottom: 10rpx;
  517. }
  518. .deficiency-name {
  519. font-size: 28rpx;
  520. color: #374151;
  521. font-weight: 500;
  522. }
  523. .deficiency-level {
  524. font-size: 24rpx;
  525. color: #9CA3AF;
  526. }
  527. .deficiency-bar {
  528. height: 12rpx;
  529. background: #F3F4F6;
  530. border-radius: 6rpx;
  531. overflow: hidden;
  532. }
  533. .deficiency-fill {
  534. height: 100%;
  535. border-radius: 6rpx;
  536. }
  537. .deficiency-foods {
  538. font-size: 22rpx;
  539. color: #6B7280;
  540. margin-top: 8rpx;
  541. }
  542. .report-list {
  543. display: flex;
  544. flex-direction: column;
  545. }
  546. .report-item {
  547. display: flex;
  548. flex-direction: row;
  549. align-items: center;
  550. justify-content: space-between;
  551. padding: 16rpx 0;
  552. border-bottom: 1rpx solid #F3F4F6;
  553. }
  554. .report-item:last-child {
  555. border-bottom: none;
  556. }
  557. .report-item:active {
  558. opacity: 0.7;
  559. }
  560. .report-item-left {
  561. display: flex;
  562. flex-direction: column;
  563. }
  564. .report-item-type {
  565. font-size: 28rpx;
  566. color: #374151;
  567. font-weight: 500;
  568. }
  569. .report-item-date {
  570. font-size: 22rpx;
  571. color: #9CA3AF;
  572. margin-top: 4rpx;
  573. }
  574. .report-item-score {
  575. display: flex;
  576. flex-direction: row;
  577. align-items: baseline;
  578. }
  579. .report-score-val {
  580. font-size: 36rpx;
  581. font-weight: bold;
  582. color: #F97316;
  583. }
  584. .report-score-label {
  585. font-size: 20rpx;
  586. color: #9CA3AF;
  587. margin-left: 4rpx;
  588. }
  589. .fab {
  590. position: fixed;
  591. bottom: 40rpx;
  592. left: 50%;
  593. transform: translateX(-50%);
  594. background: linear-gradient(135deg, #F97316, #FB923C);
  595. color: white;
  596. display: flex;
  597. flex-direction: row;
  598. align-items: center;
  599. padding: 20rpx 40rpx;
  600. border-radius: 50rpx;
  601. box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.4);
  602. z-index: 200;
  603. }
  604. .fab-icon {
  605. font-size: 36rpx;
  606. margin-right: 12rpx;
  607. }
  608. .fab-text {
  609. font-size: 30rpx;
  610. font-weight: 600;
  611. }
  612. .bottom-spacer {
  613. height: 130rpx;
  614. }
  615. </style>