Files
msh-system/msh_single_uniapp/pages/tool/food-detail.vue
scottpan d8d2025543 feat: T10 回归测试 Bug 修复与功能完善
修复 BUG-001 至 BUG-009 及 T10-1 至 T10-6 相关问题:
- 打卡积分显示与累加逻辑优化
- 食谱计算器 Tab 选中样式修复
- 食物百科列表图片与简介展示修复
- 食物详情页数据加载修复
- AI营养师差异化回复优化
- 健康知识/营养知识名称统一
- 饮食指南/科普文章详情页内容展示修复
- 帖子营养统计数据展示修复
- 社区帖子类型中文命名统一
- 帖子详情标签中文显示修复
- 食谱营养AI填充功能完善
- 食谱收藏/点赞功能修复

新增:
- ToolNutritionFillService 营养填充服务
- T10 回归测试用例 (Playwright)
- 知识文章数据 SQL 脚本

涉及模块:
- crmeb-common: VO/Request/Response 优化
- crmeb-service: 业务逻辑完善
- crmeb-front: API 接口扩展
- msh_single_uniapp: 前端页面修复
- tests/e2e: 回归测试用例
2026-03-05 09:35:00 +08:00

633 lines
17 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 || defaultFoodData.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传 name 会导致 400
console.log('[food-detail] onLoad options:', options)
console.log('[food-detail] onLoad pageParams (id/name):', 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)
try {
this.foodData.name = decodeURIComponent(String(options.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)
const data = res.data || res
console.log('[food-detail] getFoodDetail 响应:', 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) {
const errMsg = (error && (error.message || error.msg || error.errMsg || error)) ? String(error.message || error.msg || error.errMsg || error) : '未知错误'
console.error('[food-detail] 加载食物数据失败:', error)
console.error('[food-detail] loadError(用于调试):', errMsg)
// 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) {}
}
// 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>