caution-detail.vue 11 KB

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