caution-detail.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. <template>
  2. <view class="page">
  3. <view class="warning-banner">
  4. <text class="banner-icon">⚠️</text>
  5. <text class="banner-text">以下食材推荐指数低于 0,建议谨慎食用或避免</text>
  6. </view>
  7. <view class="member-bar" v-if="memberList.length > 1">
  8. <view class="member-tab" :class="{'active': currentMemberId === (item.id || item.userId)}"
  9. v-for="item in memberList" :key="getMemberKey(item)"
  10. @tap="switchMember(item)">
  11. <text class="member-name">{{ item.nickname || item.roleLabel || '全部' }}</text>
  12. </view>
  13. </view>
  14. <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
  15. <view class="food-card" v-for="(item, idx) in foods" :key="getItemKey(item)">
  16. <view class="food-header">
  17. <text class="food-name">{{ item.foodName }}</text>
  18. <view class="score-badge-group">
  19. <text class="score-value danger">{{ item.indexScore }}</text>
  20. <view class="caution-badge">戒备</view>
  21. </view>
  22. </view>
  23. <view class="change-row" v-if="item.changeDir">
  24. <text class="change-label">较上次</text>
  25. <text :class="item.changeClass">{{ item.changeText }}</text>
  26. </view>
  27. <view class="food-meta">
  28. <text class="meta-item" v-if="item.category">{{ item.category }}</text>
  29. </view>
  30. <view class="food-score-row">
  31. <view class="score-bar-wrap">
  32. <view class="score-bar-bg">
  33. <view class="score-bar-fill danger" :style="'width:' + Math.min(Math.abs(item.indexScore) / 50 * 100, 100) + '%'"></view>
  34. </view>
  35. <text class="score-label">推荐指数</text>
  36. </view>
  37. </view>
  38. <mini-trend :data="item.trendPoints" v-if="item.trendPoints && item.trendPoints.length >= 3"></mini-trend>
  39. <view class="food-nutrition" v-if="item.nutrition">
  40. <text class="nut-item">蛋白 {{ item.nutrition.protein }}g</text>
  41. <text class="nut-item">脂肪 {{ item.nutrition.fat }}g</text>
  42. <text class="nut-item">碳水 {{ item.nutrition.carbs }}g</text>
  43. </view>
  44. </view>
  45. <view class="empty-state" v-if="!loading && foods.length === 0">
  46. <text class="empty-icon">✅</text>
  47. <text class="empty-text">暂无戒备食材</text>
  48. <text class="empty-hint">您的菌群报告未显示需要戒备的食材</text>
  49. </view>
  50. <view class="loading-state" v-if="loading">
  51. <text class="loading-text">加载中...</text>
  52. </view>
  53. <view class="bottom-spacer"></view>
  54. </scroll-view>
  55. </view>
  56. </template>
  57. <script>
  58. import { getFoodCautionList, getFoodCautionTrend, getCautionFamilyMembers } from '../../utils/api.js'
  59. import MiniTrend from './mini-trend.vue'
  60. export default {
  61. components: { MiniTrend },
  62. data() {
  63. return {
  64. foods: [],
  65. loading: true,
  66. refreshing: false,
  67. nutritionCache: {},
  68. currentMemberId: null,
  69. memberList: [],
  70. trendMap: {},
  71. // 报告详情入口传入:>0 时按该报告返回戒备食材,隐藏家庭成员切换
  72. reportId: null
  73. }
  74. },
  75. onLoad: function(options) {
  76. this.reportId = options.reportId ? parseInt(options.reportId) : null
  77. this.currentMemberId = options.memberId ? parseInt(options.memberId) : null
  78. if (!this.reportId) {
  79. this.loadMemberList()
  80. }
  81. this.loadFoods()
  82. },
  83. methods: {
  84. getItemKey: function(item) { return item.foodId || item.id || Math.random() },
  85. getMemberKey: function(item) { return item.id || item.userId || 'all' },
  86. goBack: function() {
  87. uni.navigateBack()
  88. },
  89. switchMember: function(item) {
  90. this.currentMemberId = item.id || item.userId
  91. this.loadFoods()
  92. },
  93. async loadMemberList() {
  94. try {
  95. var res = await getCautionFamilyMembers()
  96. if (res && res.code === 200 && res.data && res.data.length > 0) {
  97. var allItem = { id: -1, userId: -1, nickname: '全部', roleLabel: '全部' }
  98. this.memberList = [allItem].concat(res.data)
  99. if (!this.currentMemberId) {
  100. this.currentMemberId = -1
  101. }
  102. }
  103. } catch(e) {}
  104. },
  105. async loadFoods() {
  106. this.loading = true
  107. try {
  108. var res = await getFoodCautionList(this.currentMemberId > 0 ? this.currentMemberId : null, this.reportId)
  109. if (res && res.code === 200 && res.data) {
  110. this.foods = (res.data || []).map(function(f) {
  111. return {
  112. foodId: f.foodId || f.id,
  113. foodName: f.foodName,
  114. indexScore: f.indexScore,
  115. category: f.category,
  116. nutrition: null,
  117. changeDir: null,
  118. changeText: '',
  119. changeClass: '',
  120. trendPoints: null
  121. }
  122. })
  123. this.loadNutrition()
  124. // 按报告快照展示,无历史变化概念,跳过趋势查询
  125. if (!this.reportId) {
  126. this.loadTrends()
  127. }
  128. } else {
  129. this.foods = []
  130. }
  131. } catch (e) {
  132. console.error('加载戒备食材失败', e)
  133. this.foods = []
  134. } finally {
  135. this.loading = false
  136. this.refreshing = false
  137. }
  138. },
  139. async loadNutrition() {
  140. if (!this.foods.length) return
  141. try {
  142. var kbRes = await this.getKnowledgeSection('食物库')
  143. if (kbRes && kbRes.data && kbRes.data['数据']) {
  144. var kbFoods = kbRes.data['数据']
  145. var cache = {}
  146. for (var i = 0; i < kbFoods.length; i++) {
  147. var f = kbFoods[i]
  148. cache[f['名称']] = {
  149. protein: f['蛋白g'] || null,
  150. fat: f['脂肪g'] || null,
  151. carbs: f['碳水化合物g'] || null,
  152. fiber: f['总膳食纤维g'] || null,
  153. energy: f['能量KJ'] || null,
  154. category: f['分类'] || ''
  155. }
  156. }
  157. this.nutritionCache = cache
  158. this.foods = this.foods.map(function(item) {
  159. var n = cache[item.foodName] || {}
  160. return Object.assign({}, item, {
  161. nutrition: n,
  162. category: item.category || n.category || ''
  163. })
  164. })
  165. this.$forceUpdate()
  166. }
  167. } catch (e) {
  168. console.warn('加载营养数据失败', e)
  169. }
  170. },
  171. loadTrends: function() {
  172. if (!this.foods.length) return
  173. var self = this
  174. var foodIds = this.foods.map(function(f) { return f.foodId }).filter(function(id) { return id != null })
  175. if (!foodIds.length) return
  176. getFoodCautionTrend(this.currentMemberId > 0 ? this.currentMemberId : null, foodIds).then(function(res) {
  177. if (res && res.code === 200 && res.data) {
  178. var trendMap = {}
  179. var dataArray = res.data || []
  180. for (var i = 0; i < dataArray.length; i++) {
  181. var t = dataArray[i]
  182. trendMap[t.foodId] = t
  183. }
  184. self.trendMap = trendMap
  185. self.foods = self.foods.map(function(item) {
  186. var t = trendMap[item.foodId]
  187. if (!t) return item
  188. var trendPoints = []
  189. var pts = t.trend || []
  190. for (var j = 0; j < pts.length; j++) {
  191. trendPoints.push(pts[j].score)
  192. }
  193. var changeDir = t.changeDir || 'flat'
  194. var changeText = ''
  195. var changeClass = ''
  196. if (changeDir === 'up') { changeText = '↑ 上升'
  197. changeClass = 'change-up' }
  198. else if (changeDir === 'down') { changeText = '↓ 下降'
  199. changeClass = 'change-down' }
  200. return Object.assign({}, item, {
  201. trendPoints: trendPoints.length >= 3 ? trendPoints : null,
  202. changeDir: changeDir,
  203. changeText: changeText,
  204. changeClass: changeClass
  205. })
  206. })
  207. self.$forceUpdate()
  208. }
  209. }).catch(function() {})
  210. },
  211. getKnowledgeSection: function(key) {
  212. return new Promise(function(resolve) {
  213. uni.request({
  214. url: getApp().globalData.baseApi + '/api/kb/section',
  215. method: 'POST',
  216. header: {
  217. 'Content-Type': 'application/json',
  218. 'Authorization': 'Bearer ' + (uni.getStorageSync('token') || '')
  219. },
  220. data: { key: key },
  221. success: function(res) { resolve(res.data) },
  222. fail: function() { resolve({}) }
  223. })
  224. })
  225. },
  226. onRefresh: function() {
  227. this.refreshing = true
  228. this.loadFoods()
  229. }
  230. }
  231. }
  232. </script>
  233. <style scoped>
  234. .page {
  235. min-height: 100vh;
  236. background: #f5f6fa;
  237. }
  238. .nav-placeholder { width: 80rpx; }
  239. .warning-banner {
  240. display: flex;
  241. align-items: center;
  242. gap: 12rpx;
  243. padding: 20rpx 30rpx;
  244. background: #FFF3CD;
  245. border-bottom: 1rpx solid #FFECB5;
  246. }
  247. .banner-icon { font-size: 32rpx; }
  248. .banner-text { font-size: 24rpx; color: #856404; flex: 1; }
  249. .member-bar {
  250. display: flex;
  251. overflow-x: auto;
  252. padding: 16rpx 30rpx 8rpx;
  253. background: #fff;
  254. border-bottom: 1rpx solid #f0f0f0;
  255. gap: 16rpx;
  256. -webkit-overflow-scrolling: touch;
  257. }
  258. .member-tab {
  259. flex-shrink: 0;
  260. padding: 8rpx 20rpx;
  261. border-radius: 20rpx;
  262. background: #f5f5f5;
  263. color: #666;
  264. font-size: 26rpx;
  265. font-weight: 500;
  266. }
  267. .member-tab.active {
  268. background: linear-gradient(135deg, #EF4444, #DC2626);
  269. color: #fff;
  270. }
  271. .content {
  272. padding: 20rpx 30rpx;
  273. height: calc(100vh - 160rpx);
  274. }
  275. .food-card {
  276. background: #fff;
  277. border-radius: 16rpx;
  278. padding: 24rpx;
  279. margin-bottom: 16rpx;
  280. border-left: 6rpx solid #EF4444;
  281. box-shadow: 0 2rpx 8rpx rgba(239, 68, 68, 0.08);
  282. }
  283. .food-header {
  284. display: flex;
  285. align-items: center;
  286. justify-content: space-between;
  287. margin-bottom: 8rpx;
  288. }
  289. .food-name {
  290. font-size: 32rpx;
  291. font-weight: 600;
  292. color: #1a1a1a;
  293. }
  294. .score-badge-group {
  295. display: flex;
  296. align-items: center;
  297. gap: 12rpx;
  298. }
  299. .score-value {
  300. font-size: 28rpx;
  301. font-weight: 700;
  302. color: #EF4444;
  303. min-width: 60rpx;
  304. text-align: right;
  305. }
  306. .caution-badge {
  307. padding: 4rpx 16rpx;
  308. background: #EF4444;
  309. color: #fff;
  310. border-radius: 20rpx;
  311. font-size: 22rpx;
  312. font-weight: 500;
  313. }
  314. .change-row {
  315. display: flex;
  316. align-items: center;
  317. gap: 8rpx;
  318. margin-bottom: 12rpx;
  319. font-size: 24rpx;
  320. }
  321. .change-label { color: #999; }
  322. .change-up { color: #10B981; font-weight: 600; }
  323. .change-down { color: #EF4444; font-weight: 600; }
  324. .food-meta {
  325. margin-bottom: 16rpx;
  326. }
  327. .meta-item {
  328. font-size: 24rpx;
  329. color: #888;
  330. background: #f5f5f5;
  331. padding: 4rpx 12rpx;
  332. border-radius: 8rpx;
  333. }
  334. .food-score-row {
  335. display: flex;
  336. align-items: center;
  337. gap: 16rpx;
  338. margin-bottom: 12rpx;
  339. }
  340. .score-bar-wrap {
  341. flex: 1;
  342. display: flex;
  343. align-items: center;
  344. gap: 12rpx;
  345. }
  346. .score-bar-bg {
  347. flex: 1;
  348. height: 12rpx;
  349. background: #fee2e2;
  350. border-radius: 6rpx;
  351. overflow: hidden;
  352. }
  353. .score-bar-fill {
  354. height: 100%;
  355. border-radius: 6rpx;
  356. transition: width 0.3s ease;
  357. }
  358. .score-bar-fill.danger {
  359. background: linear-gradient(90deg, #EF4444, #DC2626);
  360. }
  361. .score-label {
  362. font-size: 24rpx;
  363. color: #999;
  364. white-space: nowrap;
  365. }
  366. .food-nutrition {
  367. display: flex;
  368. gap: 20rpx;
  369. flex-wrap: wrap;
  370. }
  371. .nut-item {
  372. font-size: 22rpx;
  373. color: #999;
  374. }
  375. .empty-state {
  376. text-align: center;
  377. padding: 100rpx 40rpx;
  378. }
  379. .empty-icon {
  380. font-size: 80rpx;
  381. display: block;
  382. margin-bottom: 20rpx;
  383. }
  384. .empty-text {
  385. font-size: 30rpx;
  386. color: #333;
  387. font-weight: 500;
  388. display: block;
  389. margin-bottom: 12rpx;
  390. }
  391. .empty-hint {
  392. font-size: 24rpx;
  393. color: #999;
  394. display: block;
  395. }
  396. .loading-state {
  397. text-align: center;
  398. padding: 80rpx 0;
  399. }
  400. .loading-text {
  401. font-size: 26rpx;
  402. color: #999;
  403. }
  404. .bottom-spacer { height: 40rpx; }
  405. </style>