Files
msh-system/msh_single_uniapp/pages/tool/food-encyclopedia.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

632 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-page">
<!-- 搜索框 -->
<view class="search-container">
<view class="search-box">
<text class="search-icon">🔍</text>
<input
class="search-input"
v-model="searchText"
placeholder="搜索食物名称..."
placeholder-style="color: #9fa5c0"
@input="handleSearch"
/>
</view>
</view>
<!-- 分类标签 -->
<scroll-view class="category-scroll" scroll-x>
<view class="category-list">
<view
class="category-item"
:class="{ active: currentCategory === 'all' }"
@click="selectCategory('all')"
>
<text>全部</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'grain' }"
@click="selectCategory('grain')"
>
<text class="category-icon">🌾</text>
<text>谷薯类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'vegetable' }"
@click="selectCategory('vegetable')"
>
<text class="category-icon">🥬</text>
<text>蔬菜类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'fruit' }"
@click="selectCategory('fruit')"
>
<text class="category-icon">🍎</text>
<text>水果类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'meat' }"
@click="selectCategory('meat')"
>
<text class="category-icon">🍖</text>
<text>肉蛋类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'seafood' }"
@click="selectCategory('seafood')"
>
<text class="category-icon">🐟</text>
<text>水产类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'dairy' }"
@click="selectCategory('dairy')"
>
<text class="category-icon">🥛</text>
<text>奶类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'bean' }"
@click="selectCategory('bean')"
>
<text class="category-icon">🫘</text>
<text>豆类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'nut' }"
@click="selectCategory('nut')"
>
<text class="category-icon">🥜</text>
<text>坚果类</text>
</view>
</view>
</scroll-view>
<!-- 食物列表 -->
<scroll-view class="food-scroll" scroll-y>
<view class="food-list-container">
<view class="food-count"> {{ filteredFoodList.length }} 种食物</view>
<view class="food-list">
<view
class="food-item"
v-for="(item, index) in filteredFoodList"
:key="item.id != null ? item.id : index"
@click="goToFoodDetail(item)"
>
<view class="food-image-wrapper">
<image
class="food-image"
:src="getFoodImage(item) || defaultPlaceholder"
mode="aspectFill"
@error="onFoodImageError(item)"
></image>
<view v-if="item.warning" class="warning-badge"></view>
</view>
<view class="food-info">
<view class="food-header">
<view class="food-name-wrapper">
<text class="food-name">{{ item.name }}</text>
<view class="safety-tag" :class="item.safetyClass || 'safe'">
<text>{{ item.safety || '—' }}</text>
</view>
</view>
<view v-if="item.category" class="category-badge">
<text>{{ item.category }}</text>
</view>
</view>
<view class="nutrition-list">
<view
class="nutrition-item"
v-for="(nut, idx) in (item.nutrition || item.nutrients || [])"
:key="idx"
>
<text class="nutrition-label">{{ nut.label || '—' }}</text>
<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value != null ? nut.value : '—' }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { HTTP_REQUEST_URL } from '@/config/app.js';
export default {
data() {
// 无图时的占位图(灰色背景,与 .food-image-wrapper 背景一致)
const defaultPlaceholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkyIiBoZWlnaHQ9IjE5MiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTRlNWU3Ii8+PC9zdmc+';
return {
searchText: '',
currentCategory: 'all',
searchTimer: null,
defaultPlaceholder,
imageErrorIds: {}, // 图片加载失败时用占位图key 为 item.id
foodList: [
{
name: '香蕉',
category: '水果类',
safety: '谨慎吃',
safetyClass: 'careful',
image: 'https://www.figma.com/api/mcp/asset/480782b7-5802-454c-9021-fad8cce6c195',
warning: true,
nutrition: [
{ label: '蛋白质', value: '1.4g', colorClass: 'green' },
{ label: '钾', value: '330mg', colorClass: 'red' },
{ label: '磷', value: '28mg', colorClass: 'orange' }
],
categoryType: 'fruit'
},
{
name: '玉米笋(罐头)',
category: '蔬菜类',
safety: '放心吃',
safetyClass: 'safe',
image: 'https://www.figma.com/api/mcp/asset/59db0f38-437e-4dfa-8fc6-d3eb6d2e9840',
warning: false,
nutrition: [
{ label: '钾', value: '36mg', colorClass: 'green' },
{ label: '钙', value: '6mg', colorClass: 'green' },
{ label: '磷', value: '4mg', colorClass: 'green' }
],
categoryType: 'vegetable'
},
{
name: '五谷香',
category: '谷薯类',
safety: '放心吃',
safetyClass: 'safe',
image: 'https://www.figma.com/api/mcp/asset/61347bd7-1ab4-485f-b8d0-09c7ede49fe6',
warning: false,
nutrition: [
{ label: '钾', value: '7mg', colorClass: 'green' },
{ label: '钙', value: '2mg', colorClass: 'green' },
{ label: '磷', value: '13mg', colorClass: 'green' }
],
categoryType: 'grain'
},
{
name: '糯米粥',
category: '谷薯类',
safety: '放心吃',
safetyClass: 'safe',
image: 'https://www.figma.com/api/mcp/asset/cf95c2ea-9fb0-4e40-b134-39873207f769',
warning: false,
nutrition: [
{ label: '钾', value: '13mg', colorClass: 'green' },
{ label: '钙', value: '7mg', colorClass: 'green' },
{ label: '磷', value: '20mg', colorClass: 'green' }
],
categoryType: 'grain'
},
{
name: '苹果',
category: '水果类',
safety: '限量吃',
safetyClass: 'limited',
image: 'https://www.figma.com/api/mcp/asset/4bc870ef-b16d-496b-b9ed-16f2cb9a53e1',
warning: false,
nutrition: [
{ label: '蛋白质', value: '0.4g', colorClass: 'green' },
{ label: '钾', value: '119mg', colorClass: 'orange' },
{ label: '磷', value: '12mg', colorClass: 'green' }
],
categoryType: 'fruit'
},
{
name: '西兰花',
category: '蔬菜类',
safety: '谨慎吃',
safetyClass: 'careful',
image: 'https://www.figma.com/api/mcp/asset/eb858cbc-78cb-46b1-a9a6-8cc9684dabba',
warning: false,
nutrition: [
{ label: '蛋白质', value: '4.1g', colorClass: 'orange' },
{ label: '钾', value: '316mg', colorClass: 'red' },
{ label: '磷', value: '72mg', colorClass: 'red' }
],
categoryType: 'vegetable'
}
]
}
},
computed: {
filteredFoodList() {
return this.foodList
},
},
onLoad(options) {
if (options && options.category) {
this.currentCategory = options.category;
}
this.loadFoodList();
},
methods: {
async loadFoodList() {
try {
const { getFoodList } = await import('@/api/tool.js');
const result = await getFoodList({
category: this.currentCategory === 'all' ? '' : this.currentCategory,
page: 1,
limit: 100
});
const rawList = this.getRawFoodList(result);
this.imageErrorIds = {};
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item));
} catch (error) {
console.error('加载食物列表失败:', error);
}
},
// 兼容 result.data.list / result.list / result.data 为数组 等响应结构
getRawFoodList(result) {
if (!result) return [];
const page = result.data !== undefined && result.data !== null ? result.data : result;
if (page && Array.isArray(page.list)) return page.list;
if (Array.isArray(page)) return page;
return [];
},
getFoodImage(item) {
const id = item.id != null ? item.id : item.foodId;
if (id != null && this.imageErrorIds[String(id)]) return this.defaultPlaceholder;
const raw = item.imageUrl || item.image || item.img || item.pic || item.coverImage || '';
const url = raw && (raw.startsWith('//') || raw.startsWith('http')) ? raw : (raw && raw.startsWith('/') ? (HTTP_REQUEST_URL || '') + raw : raw);
return (url && String(url).trim()) ? url : this.defaultPlaceholder;
},
onFoodImageError(item) {
const id = item.id != null ? item.id : item.foodId;
if (id != null && !this.imageErrorIds[String(id)]) {
this.imageErrorIds[String(id)] = true;
this.imageErrorIds = { ...this.imageErrorIds };
}
},
normalizeFoodItem(item) {
const safetyMap = {
suitable: { safety: '放心吃', safetyClass: 'safe' },
moderate: { safety: '限量吃', safetyClass: 'limited' },
restricted: { safety: '谨慎吃', safetyClass: 'careful' },
forbidden: { safety: '谨慎吃', safetyClass: 'careful' }
};
const safety = item.safety != null ? { safety: item.safety, safetyClass: item.safetyClass || 'safe' } : (safetyMap[item.suitabilityLevel] || { safety: '—', safetyClass: 'safe' });
// 图片:兼容 image/imageUrl/img/pic/coverImage相对路径补全为完整 URL空则留空由 getFoodImage 用占位图
const rawImg = item.imageUrl || item.image || item.img || item.pic || item.coverImage || '';
const imageUrl = (rawImg && (rawImg.startsWith('//') || rawImg.startsWith('http'))) ? rawImg : (rawImg && rawImg.startsWith('/') ? (HTTP_REQUEST_URL || '') + rawImg : rawImg);
const image = imageUrl || '';
// 营养简介:优先 item.nutrition其次 item.nutrients兼容后端否则由扁平字段 energy/protein/potassium 等组装
let nutrition = item.nutrition;
if (Array.isArray(nutrition) && nutrition.length > 0) {
nutrition = nutrition.map(n => ({
label: n.label || n.name || n.labelName || '—',
value: n.value != null ? String(n.value) : '—',
colorClass: n.colorClass || 'green'
}));
} else if (Array.isArray(item.nutrients) && item.nutrients.length > 0) {
nutrition = item.nutrients.map(n => ({
label: n.label || n.name || n.labelName || '—',
value: n.value != null ? String(n.value) : '—',
colorClass: n.colorClass || 'green'
}));
} else {
// 后端列表仅返回扁平字段,无 nutrition/nutrients 数组,此处组装并始终展示主要项(空值显示 —)
nutrition = [];
const push = (label, val, unit) => {
const value = (val != null && val !== '') ? String(val) + (unit || '') : '—';
nutrition.push({ label, value, colorClass: 'green' });
};
push('能量', item.energy, 'kcal');
push('蛋白质', item.protein, 'g');
push('钾', item.potassium, 'mg');
push('磷', item.phosphorus, 'mg');
push('钠', item.sodium, 'mg');
push('钙', item.calcium, 'mg');
}
// 后端详情接口仅接受 Long 类型 id若列表返回的 id 为非数字(如名称),不传 id避免详情页请求 400
const rawId = item.id != null ? item.id : item.foodId;
const numericId = (rawId !== undefined && rawId !== null && rawId !== '' && !isNaN(Number(rawId)))
? (typeof rawId === 'number' ? rawId : Number(rawId))
: undefined;
return {
...item,
id: numericId,
image,
imageUrl: image || undefined,
category: item.category || '',
safety: safety.safety,
safetyClass: safety.safetyClass,
nutrition: Array.isArray(nutrition) ? nutrition : []
};
},
async selectCategory(category) {
this.currentCategory = category;
// 切换分类时清空搜索文本,避免搜索状态与分类状态冲突
this.searchText = '';
if (this.searchTimer) {
clearTimeout(this.searchTimer);
}
await this.loadFoodList();
},
handleSearch() {
// 防抖300ms 内不重复触发搜索请求
if (this.searchTimer) {
clearTimeout(this.searchTimer);
}
this.searchTimer = setTimeout(async () => {
if (this.searchText.trim()) {
try {
const { searchFood } = await import('@/api/tool.js');
const result = await searchFood({
keyword: this.searchText.trim(),
page: 1,
limit: 100
});
const rawList = this.getRawFoodList(result);
this.imageErrorIds = {};
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item));
} catch (error) {
console.error('搜索失败:', error);
}
} else {
await this.loadFoodList();
}
}, 300);
},
goToFoodDetail(item) {
// 后端详情接口仅接受 Long 类型 id仅在有有效数字 id 时传 id始终传 name 供详情页失败时展示
const rawId = item.id != null ? item.id : ''
const numericId = (rawId !== '' && rawId !== undefined && !isNaN(Number(rawId))) ? Number(rawId) : null
const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
const url = numericId !== null
? `/pages/tool/food-detail?id=${numericId}${namePart}`
: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
uni.navigateTo({ url })
}
}
}
</script>
<style lang="scss" scoped>
.food-page {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f4f5f7;
}
/* 搜索框 */
.search-container {
background: #ffffff;
padding: 32rpx;
border-bottom: 1rpx solid #d0dbea;
}
.search-box {
display: flex;
align-items: center;
height: 88rpx;
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 50rpx;
padding: 0 32rpx;
.search-icon {
font-size: 32rpx;
margin-right: 16rpx;
}
.search-input {
flex: 1;
font-size: 32rpx;
color: #3e5481;
}
}
/* 分类标签 */
.category-scroll {
background: #ffffff;
border-bottom: 1rpx solid #d0dbea;
white-space: nowrap;
}
.category-list {
display: flex;
padding: 32rpx;
gap: 16rpx;
white-space: nowrap;
}
.category-item {
display: flex;
align-items: center;
gap: 12rpx;
height: 67rpx;
padding: 0 32rpx;
border-radius: 50rpx;
border: 1rpx solid #d0dbea;
background: #ffffff;
white-space: nowrap;
.category-icon {
font-size: 28rpx;
}
text {
font-size: 28rpx;
color: #9fa5c0;
}
&.active {
background: #ff6b35;
border-color: #ff6b35;
text {
color: #ffffff;
}
}
}
/* 食物列表 */
.food-scroll {
flex: 1;
overflow-y: auto;
}
.food-list-container {
padding: 32rpx;
}
.food-count {
font-size: 28rpx;
color: #9fa5c0;
margin-bottom: 32rpx;
}
.food-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.food-item {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 32rpx;
padding: 24rpx;
display: flex;
gap: 24rpx;
}
.food-image-wrapper {
position: relative;
width: 192rpx;
height: 192rpx;
border-radius: 24rpx;
overflow: hidden;
background: #f4f5f7;
flex-shrink: 0;
}
.food-image {
width: 100%;
height: 100%;
}
.warning-badge {
position: absolute;
top: 8rpx;
left: 8rpx;
background: #ff6464;
border-radius: 12rpx;
padding: 4rpx 12rpx;
font-size: 24rpx;
}
.food-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.food-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16rpx;
}
.food-name-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
}
.food-name {
font-size: 28rpx;
color: #2e3e5c;
font-weight: 500;
}
.safety-tag {
padding: 4rpx 16rpx;
border-radius: 50rpx;
font-size: 24rpx;
&.safe {
background: #e3fff1;
color: #1fcc79;
}
&.careful {
background: #ffe8e8;
color: #ff6464;
}
&.limited {
background: #fff8e1;
color: #ff9800;
}
}
.category-badge {
background: #fff5f0;
border: 1rpx solid #ff6b35;
border-radius: 12rpx;
padding: 4rpx 16rpx;
text {
font-size: 24rpx;
color: #ff6b35;
font-weight: 500;
}
}
.nutrition-list {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.nutrition-item {
display: flex;
justify-content: space-between;
align-items: center;
.nutrition-label {
font-size: 24rpx;
color: #9fa5c0;
}
.nutrition-value {
font-size: 24rpx;
&.green {
color: #1fcc79;
}
&.red {
color: #ff6464;
}
&.orange {
color: #ff9800;
}
}
}
</style>