- 添加 Gemini 2.5 Flash 对话接口(流式+非流式) - 添加 NanoBanana 图像生成/编辑接口 - 添加 Sora2 视频生成接口(文生视频、图生视频、去水印) - 移除 models-integration 子项目(功能已迁移至主后端) - 新增测试文档和 Playwright E2E 配置 - 更新前端页面和 API 接口 - 更新后端配置和日志处理
615 lines
16 KiB
Vue
615 lines
16 KiB
Vue
<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>
|
||
|