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)"
|
@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.list(list 可能为 JSON 字符串)
|
// 再包一层:CommonResult.data.data.list(list 可能为 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)
|
||||||
|
* 优先 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) {
|
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,16 +844,32 @@ 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:非空数组优先,否则 displayNutritionList(nutrientsJson/扁平字段/默认行) */
|
/** 模板 .nutrition-item v-for:非空数组优先,否则 displayNutritionList(nutrientsJson/扁平字段/默认行) */
|
||||||
resolvedNutritionList(item) {
|
resolvedNutritionList(item) {
|
||||||
|
try {
|
||||||
const mapNutRow = (n) => {
|
const mapNutRow = (n) => {
|
||||||
|
try {
|
||||||
if (!n || typeof n !== 'object') return { label: '—', value: '—', colorClass: 'green' };
|
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 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) : '';
|
const unit = n.unit != null ? String(n.unit) : '';
|
||||||
@@ -799,13 +879,24 @@ export default {
|
|||||||
value: valueStr !== '—' && unit && !valueStr.endsWith(unit) ? (valueStr + unit) : valueStr,
|
value: valueStr !== '—' && unit && !valueStr.endsWith(unit) ? (valueStr + unit) : valueStr,
|
||||||
colorClass: n.colorClass || n.color || 'green'
|
colorClass: n.colorClass || n.color || 'green'
|
||||||
};
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { label: '—', value: '—', colorClass: 'green' };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// 勿用 nutrients||nutrition:空数组 [] 为 truthy,会挡住后面有数据的 nutrition(BUG-003)
|
// 勿用 nutrients||nutrition:空数组 [] 为 truthy,会挡住后面有数据的 nutrition(BUG-003)
|
||||||
const arr = this.firstNonEmptyNutritionArray(item);
|
const arr = this.firstNonEmptyNutritionArray(item);
|
||||||
if (arr) {
|
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);
|
return this.displayNutritionList(item);
|
||||||
|
} catch (e) {
|
||||||
|
return [
|
||||||
|
{ label: '能量', value: '—', colorClass: 'green' },
|
||||||
|
{ label: '蛋白质', value: '—', colorClass: 'green' },
|
||||||
|
{ label: '钾', value: '—', colorClass: 'green' }
|
||||||
|
];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user