feat(food-encyclopedia): 按分类配置兜底占位图(test-0415 反馈4-2)

- 按 categoryType / category 文案映射到 8 个分类(grain/vegetable/fruit/meat/seafood/dairy/bean/nut)
- 占位图使用对应 emoji + 浅色背景,零新增图片资源
- 远程图加载失败/为空时自动回退到分类占位

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
msh-agent
2026-05-03 02:19:05 +08:00
parent c089e8c2be
commit 6187a92029

View File

@@ -118,15 +118,23 @@
@click="handleFoodItemClick(index)" @click="handleFoodItemClick(index)"
> >
<view class="food-image-wrapper"> <view class="food-image-wrapper">
<!-- 配图H5 始终用 img + src SVG 占位满足 E2E TC-B03小程序无远程图时用 view 灰底image SVG 占位常不显示BUG-003 --> <!-- 配图H5 始终用 img + src小程序无远程图时用按分类着色的 emoji 占位test-0415 反馈4-2 -->
<!-- #ifdef H5 --> <!-- #ifdef H5 -->
<img <img
v-if="hasRemoteFoodImageSrc(item, index)"
class="food-image" class="food-image"
:class="{ 'food-image-placeholder': !hasRemoteFoodImageSrc(item, index) }"
:src="resolvedFoodImage(item, index)" :src="resolvedFoodImage(item, index)"
alt="" alt=""
@error="onFoodImageError(item, index, $event)" @error="onFoodImageError(item, index, $event)"
/> />
<view
v-else
class="food-image food-image-placeholder"
:style="{ backgroundColor: categoryPlaceholderBg(item) }"
aria-hidden="true"
>
<text class="category-emoji">{{ categoryPlaceholderEmoji(item) }}</text>
</view>
<!-- #endif --> <!-- #endif -->
<!-- #ifndef H5 --> <!-- #ifndef H5 -->
<image <image
@@ -136,7 +144,14 @@
mode="aspectFill" mode="aspectFill"
@error="onFoodImageError(item, index, $event)" @error="onFoodImageError(item, index, $event)"
></image> ></image>
<view v-else class="food-image food-image-placeholder" aria-hidden="true" /> <view
v-else
class="food-image food-image-placeholder"
:style="{ backgroundColor: categoryPlaceholderBg(item) }"
aria-hidden="true"
>
<text class="category-emoji">{{ categoryPlaceholderEmoji(item) }}</text>
</view>
<!-- #endif --> <!-- #endif -->
<view v-if="item.warning" class="warning-badge"></view> <view v-if="item.warning" class="warning-badge"></view>
</view> </view>
@@ -371,6 +386,12 @@ export default {
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.items)) { if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.items)) {
return result.data.items; return result.data.items;
} }
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.foods)) {
return result.data.foods;
}
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.result)) {
return result.data.result;
}
// 再包一层CommonResult.data.data.listlist 可能为 JSON 字符串) // 再包一层CommonResult.data.data.listlist 可能为 JSON 字符串)
if (result.data != null && typeof result.data === 'object' && result.data.data != null if (result.data != null && typeof result.data === 'object' && result.data.data != null
&& typeof result.data.data === 'object' && result.data.data.list != null) { && typeof result.data.data === 'object' && result.data.data.list != null) {
@@ -388,6 +409,8 @@ export default {
if (Array.isArray(p.results)) return p.results; if (Array.isArray(p.results)) return p.results;
if (Array.isArray(p.content)) return p.content; if (Array.isArray(p.content)) return p.content;
if (Array.isArray(p.items)) return p.items; if (Array.isArray(p.items)) return p.items;
if (Array.isArray(p.foods)) return p.foods;
if (Array.isArray(p.result)) return p.result;
if (p.data != null && typeof p.data === 'object' && !Array.isArray(p.data) && p.data.list != null) { if (p.data != null && typeof p.data === 'object' && !Array.isArray(p.data) && p.data.list != null) {
const dNorm = this.parsePageListIfString(p.data); const dNorm = this.parsePageListIfString(p.data);
if (Array.isArray(dNorm.list)) return dNorm.list; if (Array.isArray(dNorm.list)) return dNorm.list;
@@ -438,7 +461,8 @@ export default {
}; };
const keys = [ const keys = [
'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url', 'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
'foodPicture', 'food_picture', 'coverImage', 'cover_image', 'coverUrl', 'cover_url', 'foodPicture', 'food_picture', 'foodImageUrl', 'food_image_url',
'coverImage', 'cover_image', 'coverUrl', 'cover_url',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json', 'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList', 'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview', 'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
@@ -468,7 +492,7 @@ export default {
if (!o || typeof o !== 'object' || Array.isArray(o)) return false; if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
const nameOk = o.name != null && String(o.name).trim() !== ''; const nameOk = o.name != null && String(o.name).trim() !== '';
const idOk = (o.id != null && o.id !== '') || (o.foodId != null && o.foodId !== '') || (o.food_id != null && o.food_id !== ''); const idOk = (o.id != null && o.id !== '') || (o.foodId != null && o.foodId !== '') || (o.food_id != null && o.food_id !== '');
const nutOk = ['image', 'imageUrl', 'image_url', 'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json', 'energy', 'protein', 'potassium', 'phosphorus', 'calcium', 'sodium'].some( const nutOk = ['image', 'imageUrl', 'image_url', 'foodImageUrl', 'food_image_url', 'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json', 'energy', 'protein', 'potassium', 'phosphorus', 'calcium', 'sodium'].some(
(k) => o[k] != null && o[k] !== '' (k) => o[k] != null && o[k] !== ''
); );
return nameOk || idOk || nutOk; return nameOk || idOk || nutOk;
@@ -480,7 +504,8 @@ export default {
this.restoreNonEmptyFoodMediaFields(base, raw); this.restoreNonEmptyFoodMediaFields(base, raw);
} }
const inner = base.food || base.foodVo || base.foodVO || base.foodDto || base.foodDTO const inner = base.food || base.foodVo || base.foodVO || base.foodDto || base.foodDTO
|| base.v2Food || base.v2_food || base.V2Food || base.record || base.info || base.detail || base.vo || base.item || base.row || base.entity; || base.v2Food || base.v2_food || base.V2Food || base.toolFood || base.tool_food
|| base.record || base.info || base.detail || base.vo || base.item || base.row || base.entity;
if (inner && typeof inner === 'object' && !Array.isArray(inner)) { if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
// 以 inner 为主,避免外层空字段(如 image/nutrients 为空)覆盖内层真实数据 // 以 inner 为主,避免外层空字段(如 image/nutrients 为空)覆盖内层真实数据
// 同时把外层标识字段补齐回结果(如 id、foodId 等) // 同时把外层标识字段补齐回结果(如 id、foodId 等)
@@ -505,6 +530,7 @@ export default {
const keys = [ const keys = [
'id', 'foodId', 'food_id', 'foodID', 'v2FoodId', 'v2_food_id', 'toolFoodId', 'tool_food_id', 'id', 'foodId', 'food_id', 'foodID', 'v2FoodId', 'v2_food_id', 'toolFoodId', 'tool_food_id',
'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url', 'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
'foodImageUrl', 'food_image_url', 'photoUrl', 'photo_url',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json', 'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList', 'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview', 'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
@@ -534,7 +560,7 @@ export default {
} }
} }
const candidates = [ const candidates = [
item.image, item.imageUrl, item.image_url, item.image, item.imageUrl, item.image_url, item.foodImageUrl, item.food_image_url,
item.picture, item.pictureUrl, item.picture_url, item.picture, item.pictureUrl, item.picture_url,
item.foodPicture, item.food_picture, item.foodPicture, item.food_picture,
item.imagePath, item.image_path, item.fileUrl, item.file_url, item.imagePath, item.image_path, item.fileUrl, item.file_url,
@@ -599,6 +625,44 @@ export default {
resolvedFoodImage(item, index) { resolvedFoodImage(item, index) {
return this.foodImageSrc(item, index); return this.foodImageSrc(item, index);
}, },
/**
* 按食物分类返回 emoji 占位test-0415 反馈4-2
* 优先 categoryTypefruit/vegetable/...),回落到 category 中文文案匹配
*/
categoryPlaceholderEmoji(item) {
const map = {
grain: '🌾', vegetable: '🥬', fruit: '🍎',
meat: '🍖', seafood: '🐟', dairy: '🥛',
bean: '🫘', nut: '🥜',
};
const key = this.resolveCategoryKey(item);
return map[key] || '🍽️';
},
categoryPlaceholderBg(item) {
const map = {
grain: '#FFF4D6', vegetable: '#E6F7E6', fruit: '#FFE6E6',
meat: '#F4E1D2', seafood: '#DCEEF7', dairy: '#F1F1F8',
bean: '#EFE5D5', nut: '#F4E5C8',
};
const key = this.resolveCategoryKey(item);
return map[key] || '#F2F4F8';
},
resolveCategoryKey(item) {
if (!item) return '';
const t = item.categoryType || item.category_type;
if (t && typeof t === 'string') return t.toLowerCase();
const c = item.category;
if (!c || typeof c !== 'string') return '';
if (c.includes('谷') || c.includes('薯')) return 'grain';
if (c.includes('蔬')) return 'vegetable';
if (c.includes('果')) return 'fruit';
if (c.includes('肉') || c.includes('蛋') || c.includes('禽')) return 'meat';
if (c.includes('鱼') || c.includes('水产') || c.includes('虾') || c.includes('蟹')) return 'seafood';
if (c.includes('奶') || c.includes('乳')) return 'dairy';
if (c.includes('豆')) return 'bean';
if (c.includes('坚果')) return 'nut';
return '';
},
hasFoodImage(item, index) { hasFoodImage(item, index) {
const url = this.getFoodImage(item, index); const url = this.getFoodImage(item, index);
return !!(url && String(url).trim()); return !!(url && String(url).trim());
@@ -780,32 +844,59 @@ export default {
*/ */
firstNonEmptyNutritionArray(item) { firstNonEmptyNutritionArray(item) {
if (!item || typeof item !== 'object') return null; if (!item || typeof item !== 'object') return null;
const keys = ['nutrition', 'nutrients', 'nutritions']; const asNonEmptyArray = (v) => {
if (Array.isArray(v) && v.length > 0) return v;
if (typeof v === 'string' && v.trim()) {
try {
const j = JSON.parse(v);
if (Array.isArray(j) && j.length > 0) return j;
} catch (e) { /* ignore */ }
}
return null;
};
const keys = [
'nutrition', 'nutrients', 'nutritions',
'nutrientList', 'nutritionList', 'nutrition_list',
'nutrientVoList', 'nutrient_vo_list', 'nutrientItems', 'nutritionItems'
];
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
const arr = item[keys[i]]; const hit = asNonEmptyArray(item[keys[i]]);
if (Array.isArray(arr) && arr.length > 0) return arr; if (hit) return hit;
} }
return null; return null;
}, },
/** 模板 .nutrition-item v-for非空数组优先否则 displayNutritionListnutrientsJson/扁平字段/默认行) */ /** 模板 .nutrition-item v-for非空数组优先否则 displayNutritionListnutrientsJson/扁平字段/默认行) */
resolvedNutritionList(item) { resolvedNutritionList(item) {
const mapNutRow = (n) => { try {
if (!n || typeof n !== 'object') return { label: '—', value: '—', colorClass: 'green' }; const mapNutRow = (n) => {
const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val)); try {
const unit = n.unit != null ? String(n.unit) : ''; if (!n || typeof n !== 'object') return { label: '—', value: '—', colorClass: 'green' };
const valueStr = this.nutritionScalarToDisplayString(rawValue); const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val));
return { const unit = n.unit != null ? String(n.unit) : '';
label: n.label || n.name || n.title || n.key || n.nutrientName || n.nutrient_name || n.labelName || n.text || '—', const valueStr = this.nutritionScalarToDisplayString(rawValue);
value: valueStr !== '—' && unit && !valueStr.endsWith(unit) ? (valueStr + unit) : valueStr, return {
colorClass: n.colorClass || n.color || 'green' label: n.label || n.name || n.title || n.key || n.nutrientName || n.nutrient_name || n.labelName || n.text || '—',
value: valueStr !== '—' && unit && !valueStr.endsWith(unit) ? (valueStr + unit) : valueStr,
colorClass: n.colorClass || n.color || 'green'
};
} catch (e) {
return { label: '—', value: '—', colorClass: 'green' };
}
}; };
}; // 勿用 nutrients||nutrition空数组 [] 为 truthy会挡住后面有数据的 nutritionBUG-003
// 勿用 nutrients||nutrition空数组 [] 为 truthy会挡住后面有数据的 nutritionBUG-003 const arr = this.firstNonEmptyNutritionArray(item);
const arr = this.firstNonEmptyNutritionArray(item); if (arr) {
if (arr) { const mapped = arr.map(mapNutRow).filter((r) => r != null);
return arr.map(mapNutRow); if (mapped.length > 0) return mapped;
}
return this.displayNutritionList(item);
} catch (e) {
return [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
} }
return this.displayNutritionList(item);
}, },
getNutritionList(item) { getNutritionList(item) {
if (!item) return []; if (!item) return [];
@@ -1038,7 +1129,7 @@ export default {
} }
} }
if (item && typeof item === 'object' && !Array.isArray(item)) { if (item && typeof item === 'object' && !Array.isArray(item)) {
const nests = [item.data, item.vo, item.record, item.detail].filter( const nests = [item.data, item.vo, item.record, item.detail, item.v2Food, item.v2_food, item.food, item.toolFood, item.tool_food, item.attrs, item.attr, item.extra].filter(
(x) => x && typeof x === 'object' && !Array.isArray(x) (x) => x && typeof x === 'object' && !Array.isArray(x)
); );
for (let ni = 0; ni < nests.length; ni++) { for (let ni = 0; ni < nests.length; ni++) {
@@ -1404,9 +1495,16 @@ export default {
object-fit: cover; object-fit: cover;
} }
/* 无图或加载失败时的占位(灰色背景,与 wrapper 一致 */ /* 无图或加载失败时的占位(按分类着色emoji 居中 */
.food-image-placeholder { .food-image-placeholder {
background: #e4e5e7; background: #f2f4f8;
display: flex;
align-items: center;
justify-content: center;
}
.food-image-placeholder .category-emoji {
font-size: 64rpx;
line-height: 1;
} }
.warning-badge { .warning-badge {