Files
msh-system/msh_single_uniapp/pages/tool/food-detail.vue
scottpan 4be53dcd1b feat: 集成 KieAI 服务,移除 models-integration 子项目
- 添加 Gemini 2.5 Flash 对话接口(流式+非流式)
- 添加 NanoBanana 图像生成/编辑接口
- 添加 Sora2 视频生成接口(文生视频、图生视频、去水印)
- 移除 models-integration 子项目(功能已迁移至主后端)
- 新增测试文档和 Playwright E2E 配置
- 更新前端页面和 API 接口
- 更新后端配置和日志处理
2026-03-03 15:33:50 +08:00

615 lines
16 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="foodData.image" mode="aspectFill"></image>
<view class="image-overlay"></view>
<view class="food-info-overlay">
<view class="food-name-overlay">{{ foodData.name }}</view>
<view class="food-tags">
<view class="food-tag category">{{ foodData.category }}</view>
<view class="food-tag safe">{{ foodData.safetyTag }}</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 foodData.keyNutrients"
: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 foodData.nutritionTable"
: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: '低' }
]
}
}
},
onLoad(options) {
this.pageParams = { id: options.id || '', name: options.name || '' }
// 打印入参便于排查:后端详情接口仅接受 Long 类型 id
console.log('[food-detail] onLoad params:', this.pageParams)
const rawId = options.id
const isNumericId = rawId !== undefined && rawId !== '' && !isNaN(Number(rawId))
if (isNumericId) {
this.loadFoodData(Number(rawId))
} else if (options.name) {
// 无有效 id 仅有 name 时,不请求接口(避免传 name 导致后端 NumberFormatException直接展示默认数据 + 当前名称
this.loadError = '暂无该食物详情数据,展示参考数据'
this.applyDefaultFoodData(false)
this.foodData.name = decodeURIComponent(String(options.name))
} 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 = ''
this.foodData = {
...this.defaultFoodData,
name: this.defaultFoodData.name || '—',
category: this.defaultFoodData.category || '—',
safetyTag: this.defaultFoodData.safetyTag || '—',
image: this.defaultFoodData.image || '',
keyNutrients: [...(this.defaultFoodData.keyNutrients || [])],
nutritionTable: [...(this.defaultFoodData.nutritionTable || [])]
}
},
/** 保证数组非空,空时返回 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)
const data = res.data || res
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 返回空数据,使用默认数据
this.applyDefaultFoodData()
}
} catch (error) {
const errMsg = (error && (error.message || error.msg || error)) ? String(error.message || error.msg || error) : '未知错误'
console.error('[food-detail] 加载食物数据失败:', error)
// a. 将 loadError 置为具体错误信息(用于调试)
this.loadError = errMsg
// b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面;不清空 loadError 以便展示「当前数据来自缓存」提示
this.applyDefaultFoodData(false)
// 若有入参 name用其覆盖展示名称避免显示默认「五谷香」
if (this.pageParams && this.pageParams.name) {
try {
this.foodData.name = decodeURIComponent(String(this.pageParams.name))
} catch (e) {}
}
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>