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:
@@ -118,15 +118,23 @@
|
||||
@click="handleFoodItemClick(index)"
|
||||
>
|
||||
<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 -->
|
||||
<img
|
||||
v-if="hasRemoteFoodImageSrc(item, index)"
|
||||
class="food-image"
|
||||
:class="{ 'food-image-placeholder': !hasRemoteFoodImageSrc(item, index) }"
|
||||
:src="resolvedFoodImage(item, index)"
|
||||
alt=""
|
||||
@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 -->
|
||||
<!-- #ifndef H5 -->
|
||||
<image
|
||||
@@ -136,7 +144,14 @@
|
||||
mode="aspectFill"
|
||||
@error="onFoodImageError(item, index, $event)"
|
||||
></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 -->
|
||||
<view v-if="item.warning" class="warning-badge">⚠️</view>
|
||||
</view>
|
||||
@@ -371,6 +386,12 @@ export default {
|
||||
if (result.data != null && typeof result.data === 'object' && Array.isArray(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.list(list 可能为 JSON 字符串)
|
||||
if (result.data != null && typeof result.data === 'object' && result.data.data != 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.content)) return p.content;
|
||||
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) {
|
||||
const dNorm = this.parsePageListIfString(p.data);
|
||||
if (Array.isArray(dNorm.list)) return dNorm.list;
|
||||
@@ -438,7 +461,8 @@ export default {
|
||||
};
|
||||
const keys = [
|
||||
'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',
|
||||
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
|
||||
'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
|
||||
@@ -468,7 +492,7 @@ export default {
|
||||
if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
|
||||
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 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] !== ''
|
||||
);
|
||||
return nameOk || idOk || nutOk;
|
||||
@@ -480,7 +504,8 @@ export default {
|
||||
this.restoreNonEmptyFoodMediaFields(base, raw);
|
||||
}
|
||||
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)) {
|
||||
// 以 inner 为主,避免外层空字段(如 image/nutrients 为空)覆盖内层真实数据
|
||||
// 同时把外层标识字段补齐回结果(如 id、foodId 等)
|
||||
@@ -505,6 +530,7 @@ export default {
|
||||
const keys = [
|
||||
'id', 'foodId', 'food_id', 'foodID', 'v2FoodId', 'v2_food_id', 'toolFoodId', 'tool_food_id',
|
||||
'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
|
||||
'foodImageUrl', 'food_image_url', 'photoUrl', 'photo_url',
|
||||
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
|
||||
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
|
||||
'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
|
||||
@@ -534,7 +560,7 @@ export default {
|
||||
}
|
||||
}
|
||||
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.foodPicture, item.food_picture,
|
||||
item.imagePath, item.image_path, item.fileUrl, item.file_url,
|
||||
@@ -599,6 +625,44 @@ export default {
|
||||
resolvedFoodImage(item, index) {
|
||||
return this.foodImageSrc(item, index);
|
||||
},
|
||||
/**
|
||||
* 按食物分类返回 emoji 占位(test-0415 反馈4-2)
|
||||
* 优先 categoryType(fruit/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) {
|
||||
const url = this.getFoodImage(item, index);
|
||||
return !!(url && String(url).trim());
|
||||
@@ -780,16 +844,32 @@ export default {
|
||||
*/
|
||||
firstNonEmptyNutritionArray(item) {
|
||||
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++) {
|
||||
const arr = item[keys[i]];
|
||||
if (Array.isArray(arr) && arr.length > 0) return arr;
|
||||
const hit = asNonEmptyArray(item[keys[i]]);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/** 模板 .nutrition-item v-for:非空数组优先,否则 displayNutritionList(nutrientsJson/扁平字段/默认行) */
|
||||
resolvedNutritionList(item) {
|
||||
try {
|
||||
const mapNutRow = (n) => {
|
||||
try {
|
||||
if (!n || typeof n !== 'object') return { label: '—', value: '—', colorClass: 'green' };
|
||||
const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val));
|
||||
const unit = n.unit != null ? String(n.unit) : '';
|
||||
@@ -799,13 +879,24 @@ export default {
|
||||
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,会挡住后面有数据的 nutrition(BUG-003)
|
||||
const arr = this.firstNonEmptyNutritionArray(item);
|
||||
if (arr) {
|
||||
return arr.map(mapNutRow);
|
||||
const mapped = arr.map(mapNutRow).filter((r) => r != null);
|
||||
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' }
|
||||
];
|
||||
}
|
||||
},
|
||||
getNutritionList(item) {
|
||||
if (!item) return [];
|
||||
@@ -1038,7 +1129,7 @@ export default {
|
||||
}
|
||||
}
|
||||
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)
|
||||
);
|
||||
for (let ni = 0; ni < nests.length; ni++) {
|
||||
@@ -1404,9 +1495,16 @@ export default {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 无图或加载失败时的占位(灰色背景,与 wrapper 一致) */
|
||||
/* 无图或加载失败时的占位(按分类着色,emoji 居中) */
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user