Files
msh-system/msh_single_uniapp/pages/tool/food-detail.vue
scottpan f692c75f7b feat: 更新前端多个页面和后端服务
- 前端: 更新AI营养师、计算器、打卡、食物详情等页面
- 前端: 更新食物百科、知识详情、营养知识页面
- 前端: 更新社区首页
- 后端: 更新ToolKieAIServiceImpl服务
- API: 更新models-api.js和user.js
2026-03-07 22:26:37 +08:00

674 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="food-detail-page">
<!-- 分享按钮 -->
<view class="share-btn-top" @tap="handleShare">
<image class="share-icon" :src="iconShare" mode="aspectFit"></image>
</view>
<!-- 数据来自缓存时的提示 -->
<view v-if="loadError" class="cache-notice">
<text>当前数据来自缓存可能不是最新</text>
</view>
<!-- 内容区域 -->
<scroll-view class="content-scroll" scroll-y>
<!-- 食物大图 -->
<view class="food-image-section">
<image class="food-image" :src="displayImage" mode="aspectFill"></image>
<view class="image-overlay"></view>
<view class="food-info-overlay">
<view class="food-name-overlay">{{ displayName }}</view>
<view class="food-tags">
<view class="food-tag category">{{ displayCategory }}</view>
<view class="food-tag safe">{{ displaySafetyTag }}</view>
</view>
</view>
</view>
<!-- 关键营养成分 -->
<view class="key-nutrients-section">
<view class="section-header">
<text class="section-title">关键营养成分</text>
<view class="unit-badge">每100g</view>
</view>
<view class="key-nutrients-grid">
<view
class="nutrient-card"
v-for="(nutrient, index) in displayKeyNutrients"
:key="index"
>
<text class="nutrient-name">{{ nutrient.name }}</text>
<view class="nutrient-value">
<text class="value-number">{{ nutrient.value }}</text>
<text class="value-unit">{{ nutrient.unit }}</text>
</view>
<text class="nutrient-status">{{ nutrient.status }}</text>
</view>
</view>
</view>
<!-- 营养成分表 -->
<view class="nutrition-table-section">
<view class="section-header">
<text class="section-title">营养成分表</text>
<text class="unit-text">每100g</text>
</view>
<view class="nutrition-table">
<view
class="nutrition-row"
v-for="(item, index) in displayNutritionTable"
:key="index"
>
<view class="nutrition-left">
<view class="nutrition-dot" :class="item.level"></view>
<text class="nutrition-label">{{ item.name }}</text>
<view class="nutrition-badge" :class="item.level">
<text>{{ item.levelText }}</text>
</view>
</view>
<view class="nutrition-right">
<text class="nutrition-value" :class="item.level">{{ item.value }}</text>
<text class="nutrition-unit">{{ item.unit }}</text>
</view>
</view>
</view>
</view>
<!-- 查看相似食物按钮 -->
<view class="similar-foods-btn" @click="viewSimilarFoods">
<image class="btn-icon" :src="iconSearch" mode="aspectFit"></image>
<text>查看相似食物</text>
</view>
<!-- 底部安全距离 -->
<view class="safe-bottom"></view>
</scroll-view>
</view>
</template>
<script>
import { getFoodDetail } from '@/api/tool.js';
export default {
data() {
return {
iconShare: 'https://www.figma.com/api/mcp/asset/f9f0d7b9-89c0-48d4-9e04-7140229e42f0',
iconSearch: 'https://www.figma.com/api/mcp/asset/aa6bb75b-0a9d-43cb-aaa4-6a71993fbd4d',
loading: false,
/** 加载失败时的具体错误信息,用于调试;有值时页面会展示「当前数据来自缓存」提示 */
loadError: '',
/** 入参 id/name用于 API 失败时用 name 填充展示 */
pageParams: { id: '', name: '' },
foodData: {
name: '',
category: '',
categoryType: '',
safetyTag: '',
image: '',
keyNutrients: [],
nutritionTable: []
},
// 默认展示数据API 未返回时使用)
defaultFoodData: {
name: '五谷香',
category: '谷薯类',
categoryType: 'grain',
safetyTag: '放心吃',
image: 'https://www.figma.com/api/mcp/asset/bf4ff04c-1322-474a-bd03-5b8a49fe33ad',
keyNutrients: [
{ name: '磷', value: '13', unit: 'mg', status: '正常' },
{ name: '钾', value: '7', unit: 'mg', status: '正常' },
{ name: '钠', value: '2', unit: 'mg', status: '正常' },
{ name: '嘌呤', value: '15', unit: 'mg', status: '正常' }
],
nutritionTable: [
{ name: '钾', value: '7', unit: 'mg', level: 'low', levelText: '低' },
{ name: '钙', value: '2', unit: 'mg', level: 'low', levelText: '低' },
{ name: '磷', value: '13', unit: 'mg', level: 'low', levelText: '低' },
{ name: '蛋白质', value: '9.9', unit: 'g', level: 'medium', levelText: '中' },
{ name: '钠', value: '2', unit: 'mg', level: 'low', levelText: '低' },
{ name: '嘌呤', value: '15', unit: 'mg', level: 'low', levelText: '低' }
]
}
}
},
computed: {
// 保证 .food-name-overlay / .nutrient-card / .nutrition-row 在 defaultFoodData 状态下也有非空数据可渲染
displayName() {
return (this.foodData && this.foodData.name) ? this.foodData.name : (this.defaultFoodData.name || '—')
},
displayCategory() {
return (this.foodData && this.foodData.category) ? this.foodData.category : (this.defaultFoodData.category || '—')
},
displaySafetyTag() {
return (this.foodData && this.foodData.safetyTag) ? this.foodData.safetyTag : (this.defaultFoodData.safetyTag || '—')
},
displayImage() {
return (this.foodData && this.foodData.image) ? this.foodData.image : (this.defaultFoodData.image || '')
},
displayKeyNutrients() {
const arr = this.foodData && this.foodData.keyNutrients
if (Array.isArray(arr) && arr.length > 0) return arr
const def = this.defaultFoodData.keyNutrients
return Array.isArray(def) && def.length > 0 ? def : [{ name: '—', value: '—', unit: '', status: '—' }]
},
displayNutritionTable() {
const arr = this.foodData && this.foodData.nutritionTable
if (Array.isArray(arr) && arr.length > 0) return arr
const def = this.defaultFoodData.nutritionTable
return Array.isArray(def) && def.length > 0 ? def : [{ name: '—', value: '—', unit: '', level: 'low', levelText: '—' }]
}
},
onLoad(options) {
options = options || {}
// 若列表误将 name 传成 id如 id=羊肉(熟)),用 id 作为展示用 name避免请求接口 400
const rawId = options.id
const rawName = options.name
const hasNonNumericId = rawId !== undefined && rawId !== '' && isNaN(Number(rawId))
const displayName = rawName || (hasNonNumericId ? rawId : '')
this.pageParams = {
id: (rawId !== undefined && rawId !== '') ? String(rawId) : '',
name: (displayName !== undefined && displayName !== '') ? String(displayName) : ''
}
// 打印入参便于排查:后端详情接口仅接受 Long 类型 id传 name 会导致 400
console.log('[food-detail] onLoad options:', options)
console.log('[food-detail] onLoad pageParams (id/name):', this.pageParams)
const isNumericId = rawId !== undefined && rawId !== '' && !isNaN(Number(rawId))
if (isNumericId) {
const numId = Number(rawId)
console.log('[food-detail] 使用数字 id 请求详情,不传 name 字段:', numId)
this.loadFoodData(numId)
} else if (this.pageParams.name) {
// 无有效 id、仅有 name 时,不请求接口(避免传 name 导致后端 NumberFormatException直接展示默认数据 + 当前名称
this.loadError = '暂无该食物详情数据,展示参考数据'
this.applyDefaultFoodData(false)
try {
this.foodData.name = decodeURIComponent(this.pageParams.name)
} catch (e) {}
} else {
this.applyDefaultFoodData()
}
},
methods: {
handleShare() {
uni.showToast({
title: '分享功能开发中',
icon: 'none'
})
},
viewSimilarFoods() {
uni.navigateTo({
url: `/pages/tool/food-encyclopedia?category=${this.foodData.categoryType || 'all'}`
})
},
/** 用 defaultFoodData 填充页面,保证 keyNutrients/nutritionTable 为非空数组以便 .nutrient-card / .nutrition-row 正常渲染clearError 为 true 时顺带清空 loadError */
applyDefaultFoodData(clearError = true) {
if (clearError) this.loadError = ''
const def = this.defaultFoodData
const fallbackKey = (def.keyNutrients && def.keyNutrients.length) ? def.keyNutrients : [{ name: '—', value: '—', unit: '', status: '—' }]
const fallbackTable = (def.nutritionTable && def.nutritionTable.length) ? def.nutritionTable : [{ name: '—', value: '—', unit: '', level: 'low', levelText: '—' }]
this.foodData = {
...def,
name: (def && def.name) ? def.name : '—',
category: (def && def.category) ? def.category : '—',
safetyTag: (def && def.safetyTag) ? def.safetyTag : '—',
image: (def && def.image) ? def.image : '',
keyNutrients: Array.isArray(def.keyNutrients) && def.keyNutrients.length > 0 ? [...def.keyNutrients] : [...fallbackKey],
nutritionTable: Array.isArray(def.nutritionTable) && def.nutritionTable.length > 0 ? [...def.nutritionTable] : [...fallbackTable]
}
},
/** 保证数组非空,空时返回 fallback 的副本,用于 API 解析结果避免空数组导致列表不渲染 */
ensureNonEmptyArray(arr, fallback) {
if (Array.isArray(arr) && arr.length > 0) return arr
return Array.isArray(fallback) ? [...fallback] : []
},
async loadFoodData(id) {
this.loading = true
this.loadError = ''
// 打印 API 请求参数便于确认(后端需要 Long 类型 id
console.log('[food-detail] getFoodDetail request param:', { id, type: typeof id })
try {
const res = await getFoodDetail(id)
// 打印响应结构便于确认request 成功时 resolve 的是 res.data即 { code: 200, data: {...} }
console.log('[food-detail] getFoodDetail 响应结构:', res ? { hasData: !!res.data, code: res.code, keys: Object.keys(res || {}) } : null)
const data = res.data != null ? res.data : res
console.log('[food-detail] getFoodDetail 解析后 data:', data ? { hasName: !!data.name, hasImage: !!data.image, keys: Object.keys(data) } : null)
if (data && data.name) {
// 解析API返回的食物数据
this.foodData = {
name: data.name || '',
category: data.category || data.categoryName || '',
categoryType: data.categoryType || data.categoryCode || 'all',
safetyTag: data.safetyTag || data.safety || this.getSafetyTag(data),
image: data.image || data.imageUrl || data.coverImage || this.defaultFoodData.image,
keyNutrients: this.ensureNonEmptyArray(this.parseKeyNutrients(data), this.defaultFoodData.keyNutrients),
nutritionTable: this.ensureNonEmptyArray(this.parseNutritionTable(data), this.defaultFoodData.nutritionTable)
}
} else {
// API 返回空数据,按“失败”处理:展示默认数据 + 缓存提示
const emptyMsg = (data == null || !data.name) ? '接口返回数据为空' : '缺少食物名称'
console.warn('[food-detail] 接口返回无效数据:', data)
this.loadError = emptyMsg
this.applyDefaultFoodData(false)
if (this.pageParams && this.pageParams.name) {
try {
this.foodData.name = decodeURIComponent(String(this.pageParams.name))
} catch (e) {}
}
uni.showToast({ title: '数据加载失败', icon: 'none' })
}
} catch (error) {
// a. 将 loadError 置为具体错误信息(用于调试):兼容 Error、{ message/msg }、字符串
const errMsg = (error && (error.message || error.msg || error.errMsg))
? String(error.message || error.msg || error.errMsg)
: (typeof error === 'string' ? error : (error ? String(error) : '未知错误'))
console.error('[food-detail] 加载食物数据失败:', error)
console.error('[food-detail] loadError(用于调试):', errMsg)
this.loadError = errMsg
// b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面而不是空白
this.applyDefaultFoodData(false)
// 若有入参 name用其覆盖展示名称避免显示默认「五谷香」
if (this.pageParams && this.pageParams.name) {
try {
this.foodData.name = decodeURIComponent(String(this.pageParams.name))
} catch (e) {}
}
// c. 页面通过 v-if="loadError" 显示「当前数据来自缓存,可能不是最新」;再弹出轻提示
uni.showToast({
title: '数据加载失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
getSafetyTag(data) {
// 根据营养数据判断安全标签
if (data.safetyLevel === 'safe' || data.safetyLevel === 1) return '放心吃'
if (data.safetyLevel === 'caution' || data.safetyLevel === 2) return '少量吃'
if (data.safetyLevel === 'danger' || data.safetyLevel === 3) return '不宜吃'
return '放心吃'
},
parseKeyNutrients(data) {
if (data.keyNutrients && Array.isArray(data.keyNutrients)) {
return data.keyNutrients
}
// 从详细营养数据中提取关键营养素
const nutrients = data.nutrients || data.nutritionData || {}
const keyList = []
if (nutrients.phosphorus !== undefined) keyList.push({ name: '磷', value: String(nutrients.phosphorus), unit: 'mg', status: this.getStatus(nutrients.phosphorus, 'phosphorus') })
if (nutrients.potassium !== undefined) keyList.push({ name: '钾', value: String(nutrients.potassium), unit: 'mg', status: this.getStatus(nutrients.potassium, 'potassium') })
if (nutrients.sodium !== undefined) keyList.push({ name: '钠', value: String(nutrients.sodium), unit: 'mg', status: this.getStatus(nutrients.sodium, 'sodium') })
if (nutrients.purine !== undefined) keyList.push({ name: '嘌呤', value: String(nutrients.purine), unit: 'mg', status: this.getStatus(nutrients.purine, 'purine') })
return keyList.length > 0 ? keyList : this.defaultFoodData.keyNutrients
},
parseNutritionTable(data) {
if (data.nutritionTable && Array.isArray(data.nutritionTable)) {
return data.nutritionTable
}
const nutrients = data.nutrients || data.nutritionData || {}
const table = []
const items = [
{ key: 'potassium', name: '钾', unit: 'mg', thresholds: [200, 500] },
{ key: 'calcium', name: '钙', unit: 'mg', thresholds: [100, 300] },
{ key: 'phosphorus', name: '磷', unit: 'mg', thresholds: [100, 300] },
{ key: 'protein', name: '蛋白质', unit: 'g', thresholds: [5, 15] },
{ key: 'sodium', name: '钠', unit: 'mg', thresholds: [100, 500] },
{ key: 'purine', name: '嘌呤', unit: 'mg', thresholds: [50, 150] }
]
items.forEach(item => {
const val = nutrients[item.key]
if (val !== undefined) {
const level = val <= item.thresholds[0] ? 'low' : (val <= item.thresholds[1] ? 'medium' : 'high')
const levelText = level === 'low' ? '低' : (level === 'medium' ? '中' : '高')
table.push({ name: item.name, value: String(val), unit: item.unit, level, levelText })
}
})
return table.length > 0 ? table : this.defaultFoodData.nutritionTable
},
getStatus(value, type) {
// 简易的营养素状态判断
return '正常'
}
}
}
</script>
<style lang="scss" scoped>
.food-detail-page {
min-height: 100vh;
background: #f4f5f7;
display: flex;
flex-direction: column;
}
/* 数据来自缓存提示 */
.cache-notice {
padding: 16rpx 32rpx;
background: #fff8e1;
border-bottom: 1rpx solid #ffe082;
text-align: center;
text {
font-size: 24rpx;
color: #f57c00;
}
}
/* 分享按钮 */
.share-btn-top {
position: fixed;
top: calc(var(--status-bar-height, 0) + 20rpx);
right: 32rpx;
z-index: 100;
width: 72rpx;
height: 72rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
.share-icon {
width: 40rpx;
height: 40rpx;
}
}
/* 内容滚动区域 */
.content-scroll {
flex: 1;
padding: 0;
}
/* 食物大图区域 */
.food-image-section {
position: relative;
width: 100%;
height: 576rpx;
background: linear-gradient(180deg, #f4f5f7 0%, #ffffff 100%);
overflow: hidden;
.food-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200rpx;
background: linear-gradient(to top, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0) 100%);
}
.food-info-overlay {
position: absolute;
bottom: 32rpx;
left: 32rpx;
right: 32rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.food-name-overlay {
font-size: 48rpx;
color: #ffffff;
font-weight: 500;
text-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.15);
}
.food-tags {
display: flex;
gap: 16rpx;
}
.food-tag {
height: 48rpx;
border-radius: 12rpx;
padding: 8rpx 24rpx;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
&.category {
background: rgba(255, 255, 255, 0.95);
color: #ff6b35;
}
&.safe {
background: rgba(255, 107, 53, 0.9);
color: #ffffff;
}
}
}
/* 关键营养成分 */
.key-nutrients-section {
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
.section-title {
font-size: 32rpx;
color: #2e3e5c;
font-weight: 500;
}
.unit-badge {
background: #f4f5f7;
border-radius: 16rpx;
padding: 8rpx 24rpx;
font-size: 24rpx;
color: #9fa5c0;
}
.unit-text {
font-size: 24rpx;
color: #9fa5c0;
}
}
.key-nutrients-grid {
display: flex;
gap: 16rpx;
justify-content: space-between;
}
.nutrient-card {
background: #ffffff;
border: 2rpx solid #d0dbea;
border-radius: 32rpx;
padding: 34rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
.nutrient-name {
font-size: 40rpx;
color: #ff6b35;
font-weight: 500;
}
.nutrient-value {
display: flex;
align-items: baseline;
gap: 4rpx;
.value-number {
font-size: 24rpx;
color: #9fa5c0;
}
.value-unit {
font-size: 20rpx;
color: #9fa5c0;
}
}
.nutrient-status {
font-size: 20rpx;
color: #ff6b35;
}
}
/* 营养成分表 */
.nutrition-table-section {
padding: 0 32rpx 32rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.nutrition-table {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 32rpx;
overflow: hidden;
}
.nutrition-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26rpx 32rpx;
border-bottom: 1rpx solid #d0dbea;
&:last-child {
border-bottom: none;
}
}
.nutrition-left {
display: flex;
align-items: center;
gap: 24rpx;
flex: 1;
min-width: 0;
}
.nutrition-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
&.low {
background: #ff6b35;
}
&.medium {
background: #ffa500;
}
&.high {
background: #e53935;
}
}
.nutrition-label {
font-size: 28rpx;
color: #2e3e5c;
flex-shrink: 0;
}
.nutrition-badge {
height: 34rpx;
border-radius: 12rpx;
padding: 2rpx 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.low {
background: linear-gradient(180deg, #fff5f0 0%, #ffe8dc 100%);
border: 1rpx solid rgba(255, 107, 53, 0.3);
text {
font-size: 24rpx;
color: #ff6b35;
font-weight: 500;
}
}
&.medium {
background: linear-gradient(180deg, #fff8e1 0%, #fff4e6 100%);
border: 1rpx solid rgba(255, 165, 0, 0.3);
text {
font-size: 24rpx;
color: #ffa500;
font-weight: 500;
}
}
}
.nutrition-right {
display: flex;
align-items: baseline;
gap: 8rpx;
flex-shrink: 0;
}
.nutrition-value {
font-size: 32rpx;
color: #ff6b35;
font-weight: 500;
&.medium {
color: #ffa500;
}
}
.nutrition-unit {
font-size: 24rpx;
color: #9fa5c0;
}
/* 查看相似食物按钮 */
.similar-foods-btn {
background: #ffffff;
border: 2rpx solid #ff6b35;
border-radius: 50rpx;
height: 104rpx;
margin: 0 32rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1);
.btn-icon {
width: 32rpx;
height: 32rpx;
}
text {
font-size: 28rpx;
color: #ff6b35;
font-weight: 500;
}
}
/* 底部安全距离 */
.safe-bottom {
height: 40rpx;
}
</style>