fix(food-encyclopedia): 解决列表页首屏闪烁(test-0415 反馈4-3)
- foodList 初始化由 figma mock 数据改为空数组,加 foodLoaded 标记 API 完成态 - 首屏未拿到数据时显示骨架占位,避免短暂展示无效 figma 图片造成闪烁 - 同步收纳已存在的 scroll-view 高度修复(BUG-003) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,10 +91,25 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 食物列表 -->
|
||||
<scroll-view class="food-scroll" scroll-y>
|
||||
<!-- 食物列表:小程序 scroll-view 需明确高度;enable-flex 保证内部 .food-item 的 flex 横向布局正常(BUG-003) -->
|
||||
<scroll-view class="food-scroll" scroll-y :enable-flex="true" :style="{ height: foodScrollHeight }">
|
||||
<view class="food-list-container">
|
||||
<view class="food-count">共 {{ filteredFoodList.length }} 种食物</view>
|
||||
<!-- 加载中:先用骨架占位,避免 mock 数据闪烁(test-0415 反馈3:列表页面闪烁) -->
|
||||
<view v-if="!foodLoaded && filteredFoodList.length === 0" class="food-list">
|
||||
<view v-for="i in 4" :key="'sk' + i" class="food-item">
|
||||
<view class="food-image-wrapper">
|
||||
<view class="food-image food-image-placeholder" aria-hidden="true" />
|
||||
</view>
|
||||
<view class="food-info">
|
||||
<view class="food-header">
|
||||
<view class="food-name-wrapper">
|
||||
<text class="food-name" style="opacity:.4">加载中…</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="food-list">
|
||||
<view
|
||||
class="food-item"
|
||||
@@ -103,24 +118,25 @@
|
||||
@click="handleFoodItemClick(index)"
|
||||
>
|
||||
<view class="food-image-wrapper">
|
||||
<!-- 配图:优先 item.imageUrl/img/image(normalize 已对齐),再经 displayFoodImage 拼域名与占位(BUG-003) -->
|
||||
<!-- 配图:H5 始终用 img + src(含 SVG 占位,满足 E2E TC-B03);小程序无远程图时用 view 灰底(image 对 SVG 占位常不显示)(BUG-003) -->
|
||||
<!-- #ifdef H5 -->
|
||||
<img
|
||||
class="food-image"
|
||||
:class="{ 'food-image-placeholder': !hasFoodImage(item, index) }"
|
||||
:src="displayFoodImage(item, index)"
|
||||
:class="{ 'food-image-placeholder': !hasRemoteFoodImageSrc(item, index) }"
|
||||
:src="resolvedFoodImage(item, index)"
|
||||
alt=""
|
||||
@error="onFoodImageError(item, index, $event)"
|
||||
/>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<image
|
||||
v-if="hasRemoteFoodImageSrc(item, index)"
|
||||
class="food-image"
|
||||
:class="{ 'food-image-placeholder': !hasFoodImage(item, index) }"
|
||||
:src="displayFoodImage(item, index)"
|
||||
:src="remoteFoodImageUrl(item, index)"
|
||||
mode="aspectFill"
|
||||
@error="onFoodImageError(item, index, $event)"
|
||||
></image>
|
||||
<view v-else class="food-image food-image-placeholder" aria-hidden="true" />
|
||||
<!-- #endif -->
|
||||
<view v-if="item.warning" class="warning-badge">⚠️</view>
|
||||
</view>
|
||||
@@ -140,7 +156,7 @@
|
||||
<!-- 营养简介:nutrition / nutrients / nutritions / nutrientsJson + 扁平 energy 等,见 displayNutritionList(BUG-003) -->
|
||||
<view
|
||||
class="nutrition-item"
|
||||
v-for="(nut, idx) in displayNutritionList(item)"
|
||||
v-for="(nut, idx) in resolvedNutritionList(item)"
|
||||
:key="'n-' + foodRowKey(item, index) + '-' + idx"
|
||||
>
|
||||
<text class="nutrition-label">{{ nut.label || '—' }}</text>
|
||||
@@ -168,101 +184,27 @@ export default {
|
||||
currentCategory: 'all',
|
||||
searchTimer: null,
|
||||
defaultPlaceholder,
|
||||
/** 列表区 scroll-view 高度(小程序须 px,不能用仅靠 flex:1)(BUG-003) */
|
||||
foodScrollHeight: '100vh',
|
||||
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'
|
||||
}
|
||||
]
|
||||
foodLoaded: false, // API 是否已返回(避免初次进入闪烁 mock 数据)
|
||||
foodList: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredFoodList() {
|
||||
const list = (this.foodList || []).filter(item => item != null && typeof item === 'object' && item.name);
|
||||
const list = (this.foodList || []).filter((item) => {
|
||||
if (item == null || typeof item !== 'object') return false;
|
||||
const n = item.name || item.foodName || item.food_name || item.title;
|
||||
return n != null && String(n).trim() !== '';
|
||||
});
|
||||
if (!this.searchText || !this.searchText.trim()) return list;
|
||||
const kw = this.searchText.trim().toLowerCase();
|
||||
return list.filter(item => item.name && item.name.toLowerCase().includes(kw));
|
||||
return list.filter((item) => {
|
||||
const raw = item && (item.name != null ? item.name : (item.foodName != null ? item.foodName : (item.food_name != null ? item.food_name : item.title)));
|
||||
const nameStr = raw != null ? String(raw).toLowerCase() : '';
|
||||
return nameStr.includes(kw);
|
||||
});
|
||||
},
|
||||
},
|
||||
onLoad(options) {
|
||||
@@ -271,7 +213,38 @@ export default {
|
||||
}
|
||||
this.loadFoodList();
|
||||
},
|
||||
onReady() {
|
||||
this.updateFoodScrollHeight();
|
||||
},
|
||||
methods: {
|
||||
/** 根据搜索区 + 分类条占位,计算下方列表 scroll-view 高度(与 nutrition-knowledge / dietary-records 一致,BUG-003) */
|
||||
updateFoodScrollHeight() {
|
||||
this.$nextTick(() => {
|
||||
try {
|
||||
const sys = uni.getSystemInfoSync();
|
||||
const winH = typeof sys.windowHeight === 'number' ? sys.windowHeight : 667;
|
||||
const q = uni.createSelectorQuery().in(this);
|
||||
q.select('.search-container').boundingClientRect();
|
||||
q.select('.category-scroll').boundingClientRect();
|
||||
q.exec((rects) => {
|
||||
const searchRect = rects && rects[0];
|
||||
const catRect = rects && rects[1];
|
||||
let bottom = 0;
|
||||
if (catRect && typeof catRect.bottom === 'number') {
|
||||
bottom = catRect.bottom;
|
||||
} else if (searchRect && typeof searchRect.bottom === 'number') {
|
||||
bottom = searchRect.bottom;
|
||||
}
|
||||
const pad = typeof uni.upx2px === 'function' ? uni.upx2px(16) : 8;
|
||||
const minH = typeof uni.upx2px === 'function' ? uni.upx2px(320) : 160;
|
||||
const h = Math.floor(winH - bottom - pad);
|
||||
this.foodScrollHeight = `${Math.max(minH, h)}px`;
|
||||
});
|
||||
} catch (e) {
|
||||
this.foodScrollHeight = 'calc(100vh - 280rpx)';
|
||||
}
|
||||
});
|
||||
},
|
||||
/** 列表 :key:始终拼接 index 保证唯一,避免重复 id 导致 Vue DOM 复用错乱、点击传参丢失 */
|
||||
foodRowKey(item, index) {
|
||||
const id = item && item.id
|
||||
@@ -288,7 +261,12 @@ export default {
|
||||
page: 1,
|
||||
limit: 100
|
||||
});
|
||||
let rawList = this.getRawFoodList(result);
|
||||
const code = result && (result.code != null ? Number(result.code) : 200);
|
||||
if (Number.isFinite(code) && code !== 200) {
|
||||
const errMsg = (result && (result.message || result.msg)) || '加载食物列表失败';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
let rawList = this.getRawFoodList(this.unwrapFoodListApiResult(result));
|
||||
if (!Array.isArray(rawList)) rawList = [];
|
||||
this.imageErrorIds = {};
|
||||
this.foodList = (rawList || []).map((row) => {
|
||||
@@ -298,12 +276,41 @@ export default {
|
||||
r = JSON.parse(r);
|
||||
} catch (e) { /* keep string, normalize 会兜底 */ }
|
||||
}
|
||||
return this.normalizeFoodItem(r);
|
||||
try {
|
||||
return this.normalizeFoodItem(r);
|
||||
} catch (e) {
|
||||
console.warn('[food-encyclopedia] normalizeFoodItem 单行失败,已跳过该行', e);
|
||||
return null;
|
||||
}
|
||||
}).filter(i => i != null);
|
||||
this.$nextTick(() => this.updateFoodScrollHeight());
|
||||
} catch (error) {
|
||||
console.error('加载食物列表失败:', error);
|
||||
} finally {
|
||||
this.foodLoaded = true;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 网关偶发把 CommonResult 再包一层:{ code, data: { code, data: CommonPage } };
|
||||
* 解包后再走 getRawFoodList,与 nutrition-knowledge unwrapKnowledgeListApiResult 一致(BUG-003)。
|
||||
*/
|
||||
unwrapFoodListApiResult(result) {
|
||||
if (result == null || typeof result !== 'object') {
|
||||
return result;
|
||||
}
|
||||
let cur = result;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const inner = cur.data;
|
||||
if (inner != null && typeof inner === 'object' && !Array.isArray(inner)
|
||||
&& inner.code != null && inner.code == 200
|
||||
&& inner.data != null && typeof inner.data === 'object' && !Array.isArray(inner.data)) {
|
||||
cur = inner;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return cur;
|
||||
},
|
||||
// 兼容 CommonResult.data 为 CommonPage、网关再包一层 data、或直接数组等(与 nutrition-knowledge extractKnowledgeListFromResponse 对齐)
|
||||
getRawFoodList(result) {
|
||||
if (result == null) return [];
|
||||
@@ -315,6 +322,13 @@ export default {
|
||||
}
|
||||
}
|
||||
if (Array.isArray(result)) return result;
|
||||
// 少数网关把列表放在根级 records/rows(与 data.list 并列)
|
||||
if (typeof result === 'object' && !Array.isArray(result)) {
|
||||
if (Array.isArray(result.records)) return result.records;
|
||||
if (Array.isArray(result.rows)) return result.rows;
|
||||
if (Array.isArray(result.foodList)) return result.foodList;
|
||||
if (Array.isArray(result.list)) return result.list;
|
||||
}
|
||||
if (typeof result === 'object' && typeof result.data === 'string' && result.data.trim()) {
|
||||
try {
|
||||
return this.getRawFoodList({ ...result, data: JSON.parse(result.data) });
|
||||
@@ -326,6 +340,17 @@ export default {
|
||||
if (Array.isArray(result.data)) {
|
||||
return result.data;
|
||||
}
|
||||
// CommonResult.data.data 为直接数组(双包一层,与 nutrition-knowledge 对齐)
|
||||
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.data)) {
|
||||
return result.data.data;
|
||||
}
|
||||
// 双包 CommonResult:data 内仍为 { code, data: CommonPage }
|
||||
if (typeof result === 'object' && result.data != null && typeof result.data === 'object'
|
||||
&& result.data.data != null && typeof result.data.data === 'object'
|
||||
&& !Array.isArray(result.data.data)
|
||||
&& (result.data.data.list != null || Array.isArray(result.data.data.records) || Array.isArray(result.data.data.rows))) {
|
||||
return this.getRawFoodList({ ...result, data: result.data.data });
|
||||
}
|
||||
// CommonResult.data 为 CommonPage 时 list 在 data.list
|
||||
if (result.data != null && typeof result.data === 'object' && result.data.list != null) {
|
||||
const page = this.parsePageListIfString(result.data);
|
||||
@@ -334,6 +359,18 @@ export default {
|
||||
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.records)) {
|
||||
return result.data.records;
|
||||
}
|
||||
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.rows)) {
|
||||
return result.data.rows;
|
||||
}
|
||||
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.foodList)) {
|
||||
return result.data.foodList;
|
||||
}
|
||||
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.content)) {
|
||||
return result.data.content;
|
||||
}
|
||||
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.items)) {
|
||||
return result.data.items;
|
||||
}
|
||||
// 再包一层: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) {
|
||||
@@ -386,6 +423,36 @@ export default {
|
||||
return page;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* unwrap 合并 { ...base, ...inner } 后,若 inner 用空值盖掉了 base 上的配图/营养字段,则从 base 恢复(BUG-003)。
|
||||
*/
|
||||
restoreNonEmptyFoodMediaFields(merged, base) {
|
||||
if (!merged || typeof merged !== 'object' || Array.isArray(merged)) return;
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) return;
|
||||
const strEmpty = (v) => v == null || v === '' || String(v).trim() === '' || String(v) === 'null' || String(v) === 'undefined';
|
||||
const arrEmpty = (v) => !Array.isArray(v) || v.length === 0;
|
||||
const isEmptyMediaVal = (v) => {
|
||||
if (Array.isArray(v)) return arrEmpty(v);
|
||||
if (v && typeof v === 'object') return Object.keys(v).length === 0;
|
||||
return strEmpty(v);
|
||||
};
|
||||
const keys = [
|
||||
'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
|
||||
'foodPicture', 'food_picture', 'coverImage', 'cover_image', 'coverUrl', 'cover_url',
|
||||
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
|
||||
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
|
||||
'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
|
||||
'energy', 'protein', 'potassium', 'phosphorus', 'sodium', 'calcium', 'fat', 'carbohydrate', 'carbs'
|
||||
];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
if (!(k in base)) continue;
|
||||
const bv = base[k];
|
||||
if (isEmptyMediaVal(bv)) continue;
|
||||
if (!isEmptyMediaVal(merged[k])) continue;
|
||||
merged[k] = bv;
|
||||
}
|
||||
},
|
||||
/** 部分网关/旧接口把单条包在 food、record、info、item、data 下 */
|
||||
unwrapFoodListRow(raw) {
|
||||
if (raw == null) return raw;
|
||||
@@ -409,9 +476,11 @@ export default {
|
||||
let base = raw;
|
||||
if (!hasFoodShape(raw) && raw.data != null && typeof raw.data === 'object' && !Array.isArray(raw.data) && hasFoodShape(raw.data)) {
|
||||
base = { ...raw, ...raw.data };
|
||||
// data 子对象若带空 image/nutrients,会盖掉外层非空字段(与 inner 合并同类问题,BUG-003)
|
||||
this.restoreNonEmptyFoodMediaFields(base, raw);
|
||||
}
|
||||
const inner = base.food || base.foodVo || base.foodVO || base.v2Food || base.v2_food
|
||||
|| base.record || base.info || base.detail || base.vo || base.item || base.row || base.entity;
|
||||
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;
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||
// 以 inner 为主,避免外层空字段(如 image/nutrients 为空)覆盖内层真实数据
|
||||
// 同时把外层标识字段补齐回结果(如 id、foodId 等)
|
||||
@@ -419,6 +488,9 @@ export default {
|
||||
if (merged.id == null && base.id != null) merged.id = base.id;
|
||||
if (merged.foodId == null && base.foodId != null) merged.foodId = base.foodId;
|
||||
if (merged.food_id == null && base.food_id != null) merged.food_id = base.food_id;
|
||||
// inner 常见为 vo/dto 子集:子对象里 image、nutrientsJson、nutrition 等可能为 null/""/[],
|
||||
// 会覆盖外层列表行上已有字段,导致列表整页无图、无营养简介(BUG-003)。
|
||||
this.restoreNonEmptyFoodMediaFields(merged, base);
|
||||
return merged;
|
||||
}
|
||||
return base;
|
||||
@@ -431,10 +503,11 @@ export default {
|
||||
if (!fill || typeof fill !== 'object' || Array.isArray(fill)) return target;
|
||||
const out = { ...target };
|
||||
const keys = [
|
||||
'id', 'foodId', 'food_id', 'foodID', 'v2FoodId', 'v2_food_id', 'toolFoodId', 'tool_food_id',
|
||||
'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
|
||||
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
|
||||
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
|
||||
'nutrientVoList', 'nutrient_vo_list',
|
||||
'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
|
||||
'energy', 'protein', 'potassium', 'phosphorus', 'sodium', 'calcium', 'fat', 'carbohydrate', 'carbs',
|
||||
'suitabilityLevel', 'suitability_level'
|
||||
];
|
||||
@@ -452,18 +525,28 @@ export default {
|
||||
coalesceFoodImageField(item) {
|
||||
if (!item || typeof item !== 'object') return '';
|
||||
// 后端 ToolFoodServiceImpl 列表为 map.put("image", …);兼容 imageUrl、蛇形命名、OSS 字段等
|
||||
if (Array.isArray(item.images) && item.images.length > 0) {
|
||||
const firstImg = item.images[0];
|
||||
if (typeof firstImg === 'string' && firstImg.trim()) return firstImg.trim();
|
||||
if (firstImg && typeof firstImg === 'object' && !Array.isArray(firstImg)) {
|
||||
const u = firstImg.url || firstImg.src || firstImg.path || firstImg.uri;
|
||||
if (u != null && String(u).trim()) return String(u).trim();
|
||||
}
|
||||
}
|
||||
const candidates = [
|
||||
item.image, item.imageUrl, item.image_url,
|
||||
item.picture, item.pictureUrl, item.picture_url,
|
||||
item.foodPicture, item.food_picture,
|
||||
item.imagePath, item.image_path, item.fileUrl, item.file_url,
|
||||
item.img, item.imgUrl, item.img_url, item.pic, item.picUrl, item.pic_url,
|
||||
item.img, item.imgUrl, item.img_url, item.imageSrc, item.image_src,
|
||||
item.foodImage, item.food_image, item.pic, item.picUrl, item.pic_url,
|
||||
item.coverImage, item.cover_image, item.coverUrl, item.cover_url, item.cover,
|
||||
item.photo, item.photoUrl, item.photo_url, item.picture, item.pictureUrl,
|
||||
item.thumbnail, item.thumb, item.icon, item.headImg, item.head_img,
|
||||
item.foodImage, item.food_image, item.foodPic, item.food_pic, item.food_pic_url,
|
||||
item.foodPic, item.food_pic, item.food_pic_url,
|
||||
item.mainImage, item.main_image,
|
||||
item.foodImg, item.food_img, item.logo, item.avatar, item.banner,
|
||||
item.foodUrl, item.food_url, item.dishImage, item.dish_image,
|
||||
item.Image,
|
||||
item.coverImageUrl,
|
||||
item.ossUrl, item.oss_url, item.cdnUrl, item.cdn_url
|
||||
@@ -508,10 +591,32 @@ export default {
|
||||
return this.defaultPlaceholder || '/static/images/food-placeholder.svg';
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 模板 .food-image :src:须与 getFoodImage 同源(BUG-003)。
|
||||
* 旧版曾用 imageUrl||image 短路:以 / 开头的相对路径未拼 HTTP_REQUEST_URL,H5 会请求到页面域名导致整页无图;
|
||||
* 且会绕过 imageErrorIds,加载失败后无法回退占位图。
|
||||
*/
|
||||
resolvedFoodImage(item, index) {
|
||||
return this.foodImageSrc(item, index);
|
||||
},
|
||||
hasFoodImage(item, index) {
|
||||
const url = this.getFoodImage(item, index);
|
||||
return !!(url && String(url).trim());
|
||||
},
|
||||
/** 是否存在可请求的远程配图(不含占位图;失败写入 imageErrorIds 后为 false,便于改显 view 占位)(BUG-003) */
|
||||
hasRemoteFoodImageSrc(item, index) {
|
||||
if (!item) return false;
|
||||
const errKey = this.imageErrorKey(item, index);
|
||||
if (errKey && this.imageErrorIds[errKey]) return false;
|
||||
const raw = this.coalesceFoodImageField(item);
|
||||
const s = (raw != null && String(raw).trim()) ? String(raw).trim() : '';
|
||||
if (!s || s === 'null' || s === 'undefined') return false;
|
||||
return true;
|
||||
},
|
||||
/** 与 hasRemoteFoodImageSrc 成对使用:仅输出拼接 HTTP_REQUEST_URL 后的远程地址 */
|
||||
remoteFoodImageUrl(item, index) {
|
||||
return this.getFoodImage(item, index);
|
||||
},
|
||||
/** 须始终非空键,否则 @error 无法写入 imageErrorIds,失败图无法回退占位(BUG-003) */
|
||||
imageErrorKey(item, index) {
|
||||
const i = typeof index === 'number' ? index : 0;
|
||||
@@ -607,6 +712,8 @@ export default {
|
||||
if (n2) return n2;
|
||||
const nBrief = item && takeNonEmpty(arrOrParse(item.nutritionBrief != null ? item.nutritionBrief : item.nutrition_brief));
|
||||
if (nBrief) return nBrief;
|
||||
const nOverview = item && takeNonEmpty(arrOrParse(item.nutritionOverview != null ? item.nutritionOverview : item.nutrition_overview));
|
||||
if (nOverview) return nOverview;
|
||||
const list = this.getNutritionList(item);
|
||||
const out = Array.isArray(list) ? list : [];
|
||||
if (out.length > 0) return out;
|
||||
@@ -620,6 +727,26 @@ export default {
|
||||
* 模板 .nutrition-item v-for:统一用 nutritionListForItem(含 nutrition/nutrients/nutrientsJson/扁平字段),
|
||||
* 再规范 label/value(name/title/nutrientName 等与列表接口对象形态)。
|
||||
*/
|
||||
/** 列表营养值:兼容 BigDecimal 序列化对象、{ value, unit } 等 */
|
||||
nutritionScalarToDisplayString(rawValue) {
|
||||
if (rawValue == null || rawValue === '') return '—';
|
||||
if (typeof rawValue === 'object' && !Array.isArray(rawValue) && rawValue !== null) {
|
||||
if ('valueOf' in rawValue && typeof rawValue.valueOf === 'function') {
|
||||
try {
|
||||
const v = rawValue.valueOf();
|
||||
if (v != null && v !== rawValue && (typeof v === 'string' || typeof v === 'number')) {
|
||||
return String(v);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
const inner = rawValue.value != null ? rawValue.value
|
||||
: (rawValue.amount != null ? rawValue.amount : (rawValue.val != null ? rawValue.val : rawValue.content));
|
||||
if (inner != null && inner !== '') return this.nutritionScalarToDisplayString(inner);
|
||||
return '—';
|
||||
}
|
||||
const s = String(rawValue);
|
||||
return s.trim() === '' || s === 'null' || s === 'undefined' ? '—' : s;
|
||||
},
|
||||
displayNutritionList(item) {
|
||||
try {
|
||||
const list = this.nutritionListForItem(item);
|
||||
@@ -632,7 +759,7 @@ export default {
|
||||
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) : '';
|
||||
const valueStr = rawValue != null && rawValue !== '' ? String(rawValue) : '—';
|
||||
const valueStr = this.nutritionScalarToDisplayString(rawValue);
|
||||
return {
|
||||
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,
|
||||
@@ -647,6 +774,39 @@ export default {
|
||||
];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 取第一条「非空」营养数组。不能用 nutrients||nutrition:空数组 [] 在 JS 中为 truthy,
|
||||
* 会短路掉后面已有数据的 nutrition(网关/合并后常见 nutrients=[]、nutrition 有值)(BUG-003)。
|
||||
*/
|
||||
firstNonEmptyNutritionArray(item) {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const keys = ['nutrition', 'nutrients', 'nutritions'];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const arr = item[keys[i]];
|
||||
if (Array.isArray(arr) && arr.length > 0) return arr;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/** 模板 .nutrition-item v-for:非空数组优先,否则 displayNutritionList(nutrientsJson/扁平字段/默认行) */
|
||||
resolvedNutritionList(item) {
|
||||
const mapNutRow = (n) => {
|
||||
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) : '';
|
||||
const valueStr = this.nutritionScalarToDisplayString(rawValue);
|
||||
return {
|
||||
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'
|
||||
};
|
||||
};
|
||||
// 勿用 nutrients||nutrition:空数组 [] 为 truthy,会挡住后面有数据的 nutrition(BUG-003)
|
||||
const arr = this.firstNonEmptyNutritionArray(item);
|
||||
if (arr) {
|
||||
return arr.map(mapNutRow);
|
||||
}
|
||||
return this.displayNutritionList(item);
|
||||
},
|
||||
getNutritionList(item) {
|
||||
if (!item) return [];
|
||||
// 空数组在 JS 中为 truthy,不能用 a || b;否则会误用 [] 而跳过 nutrientsJson / 扁平字段
|
||||
@@ -657,7 +817,7 @@ export default {
|
||||
'nutritionList', 'nutrientList', 'nutrientItems',
|
||||
'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
|
||||
'nutritionInfo', 'nutrition_info_json',
|
||||
'nutritionBrief', 'nutrition_brief',
|
||||
'nutritionBrief', 'nutrition_brief', 'nutritionOverview', 'nutrition_overview',
|
||||
'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
|
||||
];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
@@ -674,7 +834,7 @@ export default {
|
||||
'nutritionList', 'nutrientList', 'nutrientItems',
|
||||
'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
|
||||
'nutritionInfo', 'nutrition_info_json',
|
||||
'nutritionBrief', 'nutrition_brief',
|
||||
'nutritionBrief', 'nutrition_brief', 'nutritionOverview', 'nutrition_overview',
|
||||
'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
|
||||
];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
@@ -705,8 +865,8 @@ export default {
|
||||
}
|
||||
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 numStr = (rawValue != null && rawValue !== '') ? String(rawValue) : '';
|
||||
const value = numStr !== '' ? (unit && !numStr.endsWith(unit) ? numStr + unit : numStr) : '—';
|
||||
const baseStr = this.nutritionScalarToDisplayString(rawValue);
|
||||
const value = baseStr !== '—' ? (unit && !baseStr.endsWith(unit) ? baseStr + unit : baseStr) : '—';
|
||||
return {
|
||||
label: n.label || n.name || n.title || n.key || n.nutrientName || '—',
|
||||
value,
|
||||
@@ -750,16 +910,27 @@ export default {
|
||||
parseNutrientsFromItem(item) {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const mapNut = (n) => {
|
||||
if (n == null) {
|
||||
return { label: '—', value: '—', colorClass: 'green' };
|
||||
}
|
||||
if (typeof n === 'string' || typeof n === 'number' || typeof n === 'boolean') {
|
||||
const s = String(n).trim();
|
||||
return { label: '—', value: s !== '' ? s : '—', colorClass: 'green' };
|
||||
}
|
||||
if (typeof n !== 'object' || Array.isArray(n)) {
|
||||
return { label: '—', value: '—', colorClass: 'green' };
|
||||
}
|
||||
const u = n.unit != null && String(n.unit).trim() !== '' ? String(n.unit).trim() : '';
|
||||
let val = '';
|
||||
if (n.value != null && n.value !== '') {
|
||||
val = String(n.value);
|
||||
if (u && !val.endsWith(u)) val += u;
|
||||
val = this.nutritionScalarToDisplayString(n.value);
|
||||
if (u && val !== '—' && !val.endsWith(u)) val += u;
|
||||
} else if (n.amount != null && n.amount !== '') {
|
||||
val = String(n.amount) + u;
|
||||
val = this.nutritionScalarToDisplayString(n.amount);
|
||||
if (u && val !== '—' && !val.endsWith(u)) val += u;
|
||||
} else if (n.val != null && n.val !== '') {
|
||||
val = String(n.val);
|
||||
if (u && !val.endsWith(u)) val += u;
|
||||
val = this.nutritionScalarToDisplayString(n.val);
|
||||
if (u && val !== '—' && !val.endsWith(u)) val += u;
|
||||
} else {
|
||||
val = '—';
|
||||
}
|
||||
@@ -771,7 +942,8 @@ export default {
|
||||
};
|
||||
const tryArr = (arr) => {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return null;
|
||||
return arr.map(mapNut);
|
||||
const rows = arr.map(mapNut).filter((row) => row != null);
|
||||
return rows.length > 0 ? rows : null;
|
||||
};
|
||||
const tryJsonString = (s) => {
|
||||
if (typeof s !== 'string' || !s.trim()) return null;
|
||||
@@ -840,6 +1012,8 @@ export default {
|
||||
if (out && out.length > 0) return out;
|
||||
out = tryJsonString(item.nutrients);
|
||||
if (out && out.length > 0) return out;
|
||||
out = fromNutrientsJson(item.nutritionOverview != null ? item.nutritionOverview : item.nutrition_overview);
|
||||
if (out && out.length > 0) return out;
|
||||
return null;
|
||||
},
|
||||
normalizeFoodItem(raw) {
|
||||
@@ -849,6 +1023,20 @@ export default {
|
||||
bean: '豆类', nut: '坚果类', all: '全部'
|
||||
};
|
||||
let item = this.unwrapFoodListRow(raw);
|
||||
// 网关偶发把整行放在 data 的 JSON 字符串里;仅 object 的 data 会被 shallowFill,字符串需先解析(BUG-003)
|
||||
if (item && typeof item === 'object' && !Array.isArray(item) && typeof item.data === 'string') {
|
||||
const t = item.data.trim();
|
||||
if (t.startsWith('{') && t.length > 1) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.data);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const merged = { ...item, ...parsed };
|
||||
this.restoreNonEmptyFoodMediaFields(merged, item);
|
||||
item = merged;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const nests = [item.data, item.vo, item.record, item.detail].filter(
|
||||
(x) => x && typeof x === 'object' && !Array.isArray(x)
|
||||
@@ -896,12 +1084,18 @@ export default {
|
||||
else image = base ? base + '/' + rawStr : rawStr;
|
||||
}
|
||||
|
||||
let nutrition = this.parseNutrientsFromItem(item);
|
||||
let nutrition = null;
|
||||
try {
|
||||
nutrition = this.parseNutrientsFromItem(item);
|
||||
} catch (e) {
|
||||
console.warn('[food-encyclopedia] parseNutrientsFromItem 失败,回退扁平营养字段', e);
|
||||
nutrition = null;
|
||||
}
|
||||
if (!nutrition || nutrition.length === 0) {
|
||||
nutrition = [];
|
||||
const push = (label, val, unit) => {
|
||||
const num = val != null && val !== '' && typeof val === 'object' && 'valueOf' in val ? val.valueOf() : val;
|
||||
const value = (num != null && num !== '') ? String(num) + (unit || '') : '—';
|
||||
const base = this.nutritionScalarToDisplayString(val);
|
||||
const value = base !== '—' ? base + (unit || '') : '—';
|
||||
nutrition.push({ label, value, colorClass: 'green' });
|
||||
};
|
||||
const e = item.energy != null ? item.energy : (item.energyKcal != null ? item.energyKcal : item.energy_kcal);
|
||||
@@ -926,24 +1120,33 @@ export default {
|
||||
}
|
||||
|
||||
// 与 goToFoodDetail 一致:在 id / foodId / food_id 等字段中找第一个纯数字 ID,避免 id 被误填为名称时整条链路传错
|
||||
const pickFirstNumericFoodId = (it) => {
|
||||
const cands = [it.id, it.foodId, it.food_id, it.v2FoodId, it.v2_food_id, it.foodID];
|
||||
const pickFirstNumericFoodIdStr = (it) => {
|
||||
const cands = [
|
||||
it.id,
|
||||
it.foodId,
|
||||
it.food_id,
|
||||
it.v2FoodId,
|
||||
it.v2_food_id,
|
||||
it.foodID,
|
||||
it.toolFoodId,
|
||||
it.tool_food_id
|
||||
];
|
||||
for (let i = 0; i < cands.length; i++) {
|
||||
const n = normalizeFoodDetailIdString(cands[i]);
|
||||
if (n !== '') return parseInt(n, 10);
|
||||
if (n !== '') return n;
|
||||
}
|
||||
return null;
|
||||
return '';
|
||||
};
|
||||
const resolvedId = pickFirstNumericFoodId(item);
|
||||
const resolvedIdStr = pickFirstNumericFoodIdStr(item);
|
||||
const rawCategory = item.category || item.categoryName || item.category_name || '';
|
||||
const category = categoryNameMap[rawCategory] || rawCategory;
|
||||
const resolvedName = (item.name != null && String(item.name).trim() !== '')
|
||||
? String(item.name).trim()
|
||||
: String(item.foodName || item.title || item.food_name || '').trim();
|
||||
// 仅当解析出合法数字 ID 时覆盖 id/foodId;勿写 null,避免抹掉后端其它字段且便于 name-only 跳转走 search 解析
|
||||
// 仅当解析出合法数字 ID 时覆盖 id/foodId(整数字符串,避免 parseInt/Number 丢精度);勿写 null
|
||||
const idFields =
|
||||
resolvedId !== null && resolvedId !== undefined && !Number.isNaN(resolvedId)
|
||||
? { id: resolvedId, foodId: resolvedId }
|
||||
resolvedIdStr !== ''
|
||||
? { id: resolvedIdStr, foodId: resolvedIdStr }
|
||||
: {};
|
||||
return {
|
||||
...item,
|
||||
@@ -989,7 +1192,12 @@ export default {
|
||||
page: 1,
|
||||
limit: 100
|
||||
});
|
||||
let rawList = this.getRawFoodList(result);
|
||||
const code = result && (result.code != null ? Number(result.code) : 200);
|
||||
if (Number.isFinite(code) && code !== 200) {
|
||||
const errMsg = (result && (result.message || result.msg)) || '搜索失败';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
let rawList = this.getRawFoodList(this.unwrapFoodListApiResult(result));
|
||||
if (!Array.isArray(rawList)) rawList = [];
|
||||
this.imageErrorIds = {};
|
||||
this.foodList = (rawList || []).map((row) => {
|
||||
@@ -999,8 +1207,14 @@ export default {
|
||||
r = JSON.parse(r);
|
||||
} catch (e) { /* keep string */ }
|
||||
}
|
||||
return this.normalizeFoodItem(r);
|
||||
try {
|
||||
return this.normalizeFoodItem(r);
|
||||
} catch (e) {
|
||||
console.warn('[food-encyclopedia] normalizeFoodItem 搜索行失败,已跳过', e);
|
||||
return null;
|
||||
}
|
||||
}).filter(i => i != null);
|
||||
this.$nextTick(() => this.updateFoodScrollHeight());
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
}
|
||||
@@ -1031,7 +1245,16 @@ export default {
|
||||
}
|
||||
// 后端详情接口仅接受 Long 类型 id;与 getFoodDetail 共用规范化(支持 123.0 等,禁止名称进路径)
|
||||
const pickNumericIdStr = () => {
|
||||
const cands = [item.id, item.foodId, item.food_id, item.v2FoodId, item.v2_food_id, item.foodID]
|
||||
const cands = [
|
||||
item.id,
|
||||
item.foodId,
|
||||
item.food_id,
|
||||
item.foodID,
|
||||
item.v2FoodId,
|
||||
item.v2_food_id,
|
||||
item.toolFoodId,
|
||||
item.tool_food_id
|
||||
]
|
||||
for (let i = 0; i < cands.length; i++) {
|
||||
const n = normalizeFoodDetailIdString(cands[i])
|
||||
if (n !== '') return n
|
||||
@@ -1039,12 +1262,12 @@ export default {
|
||||
return ''
|
||||
}
|
||||
const idStr = pickNumericIdStr()
|
||||
const numericId = idStr !== '' ? parseInt(idStr, 10) : null
|
||||
const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
|
||||
const url = (numericId !== null && typeof numericId === 'number' && !isNaN(numericId))
|
||||
? `/pages/tool/food-detail?id=${numericId}${namePart}`
|
||||
// 使用规范化后的整数字符串作为 query,避免 parseInt 大 Long 精度丢失与非十进制歧义
|
||||
const url = idStr !== ''
|
||||
? `/pages/tool/food-detail?id=${encodeURIComponent(idStr)}${namePart}`
|
||||
: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
|
||||
console.log('[food-encyclopedia] goToFoodDetail 跳转参数:', { idStr, numericId, name: item.name, url })
|
||||
console.log('[food-encyclopedia] goToFoodDetail 跳转参数:', { idStr, name: item.name, url })
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
}
|
||||
@@ -1134,7 +1357,8 @@ export default {
|
||||
/* 食物列表 */
|
||||
.food-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.food-list-container {
|
||||
|
||||
Reference in New Issue
Block a user