Files
msh-system/msh_single_uniapp/pages/tool/food-encyclopedia.vue
msh-agent 6187a92029 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>
2026-05-03 02:19:05 +08:00

1616 lines
60 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 需明确高度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"
v-for="(item, index) in filteredFoodList"
:key="foodRowKey(item, index)"
@click="handleFoodItemClick(index)"
>
<view class="food-image-wrapper">
<!-- 配图H5 始终用 img + src小程序无远程图时用按分类着色的 emoji 占位test-0415 反馈4-2 -->
<!-- #ifdef H5 -->
<img
v-if="hasRemoteFoodImageSrc(item, index)"
class="food-image"
: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
v-if="hasRemoteFoodImageSrc(item, index)"
class="food-image"
:src="remoteFoodImageUrl(item, index)"
mode="aspectFill"
@error="onFoodImageError(item, index, $event)"
></image>
<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>
<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">
<!-- 营养简介nutrition / nutrients / nutritions / nutrientsJson + 扁平 energy displayNutritionListBUG-003 -->
<view
class="nutrition-item"
v-for="(nut, idx) in resolvedNutritionList(item)"
:key="'n-' + foodRowKey(item, index) + '-' + 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';
import { normalizeFoodDetailIdString } from '@/api/tool.js';
export default {
data() {
// 本地占位图(小程序/H5 均可用;勿用 data: URL部分端 image 组件不展示)
const defaultPlaceholder = '/static/images/food-placeholder.svg';
return {
searchText: '',
currentCategory: 'all',
searchTimer: null,
defaultPlaceholder,
/** 列表区 scroll-view 高度(小程序须 px不能用仅靠 flex:1BUG-003 */
foodScrollHeight: '100vh',
imageErrorIds: {}, // 图片加载失败时用占位图key 为 item.id
foodLoaded: false, // API 是否已返回(避免初次进入闪烁 mock 数据)
foodList: []
}
},
computed: {
filteredFoodList() {
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) => {
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) {
if (options && options.category) {
this.currentCategory = options.category;
}
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
const idStr = id !== undefined && id !== null && id !== '' ? String(id).trim() : ''
if (idStr !== '' && /^-?\d+$/.test(idStr)) return 'id-' + idStr + '-' + index
const n = item && item.name != null ? String(item.name).trim() : ''
return 'idx-' + index + '-' + (n || 'row')
},
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 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) => {
let r = row;
if (typeof r === 'string' && r.trim()) {
try {
r = JSON.parse(r);
} catch (e) { /* keep string, normalize 会兜底 */ }
}
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 [];
if (typeof result === 'string' && result.trim()) {
try {
return this.getRawFoodList(JSON.parse(result));
} catch (e) {
return [];
}
}
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) });
} catch (e) {
/* ignore */
}
}
// 少数网关/旧版直接把列表放在 data数组
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;
}
// 双包 CommonResultdata 内仍为 { 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);
if (Array.isArray(page.list)) return page.list;
}
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;
}
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 字符串)
if (result.data != null && typeof result.data === 'object' && result.data.data != null
&& typeof result.data.data === 'object' && result.data.data.list != null) {
const inner = this.parsePageListIfString(result.data.data);
if (Array.isArray(inner.list)) return inner.list;
}
const page = result.data !== undefined && result.data !== null ? result.data : result;
const pick = (p) => {
if (p == null || typeof p !== 'object' || Array.isArray(p)) return null;
if (p.data && Array.isArray(p.data)) return p.data;
const pNorm = this.parsePageListIfString(p);
if (Array.isArray(pNorm.list)) return pNorm.list;
if (Array.isArray(p.records)) return p.records;
if (Array.isArray(p.rows)) return p.rows;
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;
}
if (p.data && Array.isArray(p.data.list)) return p.data.list;
if (p.data && Array.isArray(p.data.records)) return p.data.records;
return null;
};
let list = pick(page);
if (list) return list;
if (page != null && typeof page === 'object' && !Array.isArray(page) && page.data != null && typeof page.data === 'object' && !Array.isArray(page.data)) {
list = pick(page.data);
if (list) return list;
}
if (Array.isArray(page)) return page;
if (result && typeof result === 'object' && result.list != null) {
const rNorm = this.parsePageListIfString(result);
if (Array.isArray(rNorm.list)) return rNorm.list;
}
if (result && Array.isArray(result.records)) return result.records;
if (result.data && Array.isArray(result.data)) return result.data;
return [];
},
/** 分页对象里 list 偶发为 JSON 字符串;解析失败则原样返回 */
parsePageListIfString(page) {
if (page == null || typeof page !== 'object' || Array.isArray(page)) return page;
const L = page.list;
if (typeof L !== 'string' || !L.trim()) return page;
try {
const parsed = JSON.parse(L);
return Array.isArray(parsed) ? { ...page, list: parsed } : page;
} catch (e) {
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', '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',
'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;
if (typeof raw === 'string' && raw.trim()) {
try {
return this.unwrapFoodListRow(JSON.parse(raw));
} catch (e) {
return raw;
}
}
if (typeof raw !== 'object' || Array.isArray(raw)) return raw;
const hasFoodShape = (o) => {
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', '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;
};
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.foodDto || base.foodDTO
|| 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 等)
const merged = { ...base, ...inner };
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;
},
/**
* 列表行外层常有 id/name图片与营养在 data/vo 内;仅在目标字段为空时用 fill 补齐BUG-003
*/
shallowFillEmptyFields(target, fill) {
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
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',
'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',
'energy', 'protein', 'potassium', 'phosphorus', 'sodium', 'calcium', 'fat', 'carbohydrate', 'carbs',
'suitabilityLevel', 'suitability_level'
];
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!(k in fill)) continue;
const v = fill[k];
if (v == null || v === '') continue;
const cur = out[k];
const curEmpty = cur == null || cur === '' || (Array.isArray(cur) && cur.length === 0);
if (curEmpty) out[k] = v;
}
return out;
},
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.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,
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.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
];
for (let i = 0; i < candidates.length; i++) {
const v = candidates[i];
if (Array.isArray(v) && v.length > 0) {
const first = v[0];
if (first != null && typeof first === 'object' && !Array.isArray(first)) {
const nested = first.url || first.src || first.path || first.uri;
if (nested != null && String(nested).trim() !== '') return String(nested).trim();
} else if (first != null && String(first).trim() !== '' && String(first) !== 'null') {
return String(first).trim();
}
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const nested = v.url || v.src || v.path || v.uri;
if (nested != null && String(nested).trim() !== '') return String(nested).trim();
continue;
}
if (v != null && String(v).trim() !== '' && String(v) !== 'null' && String(v) !== 'undefined') {
return String(v).trim();
}
}
return '';
},
/** .food-image 的 :src有效图址优先否则本地占位保证 src 非空,便于各端与 E2E */
foodImageSrc(item, index) {
const resolved = this.getFoodImage(item, index);
const placeholder = this.defaultPlaceholder || '/static/images/food-placeholder.svg';
return (resolved && String(resolved).trim()) ? String(resolved).trim() : placeholder;
},
/**
* 模板 .food-image :src必须走 getFoodImage/foodImageSrc含 coalesce、base、imageErrorIds、占位图
* 禁止仅用 imageUrl/image 短路返回,否则会绕过加载失败后的占位与部分字段映射。
*/
displayFoodImage(item, index) {
try {
return this.foodImageSrc(item, index);
} catch (e) {
return this.defaultPlaceholder || '/static/images/food-placeholder.svg';
}
},
/**
* 模板 .food-image :src须与 getFoodImage 同源BUG-003
* 旧版曾用 imageUrl||image 短路:以 / 开头的相对路径未拼 HTTP_REQUEST_URLH5 会请求到页面域名导致整页无图;
* 且会绕过 imageErrorIds加载失败后无法回退占位图。
*/
resolvedFoodImage(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) {
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;
if (!item) {
return 'row:empty@' + i;
}
const id = item.id != null ? item.id : (item.foodId != null ? item.foodId : item.food_id);
if (id != null && String(id).trim() !== '') return 'id:' + String(id) + '@' + i;
const n = item.name != null ? String(item.name).trim() : '';
if (n) return 'name:' + n + '@' + i;
return 'idx:' + i + ':' + (n || 'row');
},
getFoodImage(item, index) {
if (!item) return '';
const errKey = this.imageErrorKey(item, index);
if (errKey && this.imageErrorIds[errKey]) return '';
const raw = this.coalesceFoodImageField(item);
const s = (raw != null && String(raw).trim()) ? String(raw).trim() : '';
if (!s || s === 'null' || s === 'undefined') return '';
if (s.startsWith('data:')) return s;
const base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim()) ? String(HTTP_REQUEST_URL).replace(/\/$/, '') : '';
const url = (s.startsWith('//') || s.startsWith('http')) ? s : (s.startsWith('/') ? base + s : (base ? base + '/' + s : s));
const finalUrl = (url && String(url).trim()) ? url : '';
if (!finalUrl) return '';
if (finalUrl.startsWith('data:') || finalUrl.startsWith('//') || finalUrl.startsWith('http') || finalUrl.startsWith('/')) return finalUrl;
// 无前导斜杠的相对路径:有 base 时已拼成绝对地址;无 base 时仍返回相对路径,由当前站点根路径解析(避免误当成无图)
if (base) {
return base + '/' + finalUrl.replace(/^\//, '');
}
return finalUrl;
},
nutritionListForItem(item) {
// normalizeFoodItem 已将 nutrientsJson + 扁平营养字段合并为 nutrition须优先使用避免 nutrientsJson
// 先被解析出少量无效行而提前 return导致忽略已合并好的 item.nutritionBUG-003 / TC-B03
if (item && Array.isArray(item.nutrition) && item.nutrition.length > 0) {
return item.nutrition;
}
if (item && Array.isArray(item.nutrients) && item.nutrients.length > 0) {
return item.nutrients;
}
if (item && Array.isArray(item.nutritions) && item.nutritions.length > 0) {
return item.nutritions;
}
// 后端 ToolFoodServiceImpl 列表字段为 nutrientsJson字符串或已解析对象用于未走 normalize 的兜底
const njEarly =
item && (item.nutrientsJson != null && item.nutrientsJson !== ''
? item.nutrientsJson
: (item.nutrients_json != null && item.nutrients_json !== '' ? item.nutrients_json : null));
if (njEarly != null) {
const fromNj = this.parseNutrientsFromItem({ ...item, nutrientsJson: njEarly });
if (fromNj && fromNj.length > 0) return fromNj;
}
// 优先使用常见字段nutrition / nutrients / nutritions列表接口可能为 JSON 字符串或对象)
// 注意:空数组 [] 在 JS 中为 truthy若 JSON 解析得到 {} 曾返回 [],会导致 if (n) 误判并吞掉扁平字段回退
const arrOrParse = (v) => {
if (Array.isArray(v) && v.length > 0) return v;
if (v && typeof v === 'object' && !Array.isArray(v)) {
const keys = Object.keys(v);
if (keys.length > 0) {
return keys.slice(0, 12).map((k) => ({
label: k,
value: v[k] != null ? String(v[k]) : '—',
colorClass: 'green'
}));
}
return null;
}
if (typeof v === 'string' && v.trim()) {
try {
const j = JSON.parse(v);
if (Array.isArray(j) && j.length > 0) return j;
if (j && typeof j === 'object' && !Array.isArray(j)) {
const keys = Object.keys(j);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: j[k] != null ? String(j[k]) : '—',
colorClass: 'green'
}));
}
} catch (e) {
return null;
}
}
return null;
};
const takeNonEmpty = (a) => (Array.isArray(a) && a.length > 0 ? a : null);
const n0 = item && takeNonEmpty(arrOrParse(item.nutrition));
if (n0) return n0;
const n1 = item && takeNonEmpty(arrOrParse(item.nutrients));
if (n1) return n1;
const n2 = item && takeNonEmpty(arrOrParse(item.nutritions));
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;
return [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
},
/**
* 模板 .nutrition-item v-for统一用 nutritionListForItem含 nutrition/nutrients/nutrientsJson/扁平字段),
* 再规范 label/valuename/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);
const rows = Array.isArray(list) && list.length > 0 ? list : [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
return rows.map((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'
};
});
} catch (e) {
return [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
}
},
/**
* 取第一条「非空」营养数组。不能用 nutrients||nutrition空数组 [] 在 JS 中为 truthy
* 会短路掉后面已有数据的 nutrition网关/合并后常见 nutrients=[]、nutrition 有值BUG-003
*/
firstNonEmptyNutritionArray(item) {
if (!item || typeof item !== 'object') return null;
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 hit = asNonEmptyArray(item[keys[i]]);
if (hit) return hit;
}
return null;
},
/** 模板 .nutrition-item v-for非空数组优先否则 displayNutritionListnutrientsJson/扁平字段/默认行) */
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) : '';
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'
};
} catch (e) {
return { label: '—', value: '—', colorClass: 'green' };
}
};
// 勿用 nutrients||nutrition空数组 [] 为 truthy会挡住后面有数据的 nutritionBUG-003
const arr = this.firstNonEmptyNutritionArray(item);
if (arr) {
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 [];
// 空数组在 JS 中为 truthy不能用 a || b否则会误用 [] 而跳过 nutrientsJson / 扁平字段
const pickNutritionArray = () => {
const keys = [
'nutrition', 'nutrients', 'nutritions',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutritionList', 'nutrientList', 'nutrientItems',
'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
'nutritionInfo', 'nutrition_info_json',
'nutritionBrief', 'nutrition_brief', 'nutritionOverview', 'nutrition_overview',
'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
];
for (let i = 0; i < keys.length; i++) {
const v = item[keys[i]];
if (Array.isArray(v) && v.length > 0) return v;
}
return null;
};
let arr = pickNutritionArray();
if (arr == null) {
const keys = [
'nutrition', 'nutrients', 'nutritions',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutritionList', 'nutrientList', 'nutrientItems',
'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
'nutritionInfo', 'nutrition_info_json',
'nutritionBrief', 'nutrition_brief', 'nutritionOverview', 'nutrition_overview',
'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
];
for (let i = 0; i < keys.length; i++) {
const v = item[keys[i]];
if (v == null || Array.isArray(v)) continue;
if (typeof v === 'string' && !v.trim()) continue;
arr = v;
break;
}
}
if (typeof arr === 'string' && arr.trim()) {
try {
const j = JSON.parse(arr);
if (Array.isArray(j)) arr = j;
else if (j && typeof j === 'object') arr = j;
else arr = null;
} catch (e) {
arr = null;
}
}
if (arr && typeof arr === 'object' && !Array.isArray(arr)) {
arr = Object.keys(arr).slice(0, 12).map((k) => ({ label: k, value: arr[k], colorClass: 'green' }));
}
if (Array.isArray(arr) && arr.length > 0) {
return arr.map((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 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,
colorClass: n.colorClass || n.color || 'green'
};
});
}
const parsed = this.parseNutrientsFromItem(item);
if (parsed && parsed.length > 0) return parsed;
const list = [];
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 || '') : '—';
list.push({ label, value, colorClass: 'green' });
};
const e = item.energy != null ? item.energy : (item.energyKcal != null ? item.energyKcal : item.energy_kcal);
const p = item.protein != null ? item.protein : (item.proteinG != null ? item.proteinG : item.protein_g);
const k = item.potassium != null ? item.potassium : (item.potassiumMg != null ? item.potassiumMg : item.potassium_mg);
const ph = item.phosphorus != null ? item.phosphorus : (item.phosphorusMg != null ? item.phosphorusMg : item.phosphorus_mg);
const na = item.sodium != null ? item.sodium : (item.sodiumMg != null ? item.sodiumMg : item.sodium_mg);
const ca = item.calcium != null ? item.calcium : (item.calciumMg != null ? item.calciumMg : item.calcium_mg);
push('能量', e, 'kcal');
push('蛋白质', p, 'g');
push('钾', k, 'mg');
push('磷', ph, 'mg');
push('钠', na, 'mg');
push('钙', ca, 'mg');
return list.length > 0 ? list : [{ label: '能量', value: '—', colorClass: 'green' }, { label: '蛋白质', value: '—', colorClass: 'green' }, { label: '钾', value: '—', colorClass: 'green' }];
},
onFoodImageError(item, index, ev) {
try {
const src = ev && ev.target && (ev.target.src || ev.target.currentSrc);
if (src && String(src).indexOf('food-placeholder') !== -1) return;
} catch (e) { /* ignore */ }
const key = this.imageErrorKey(item, index);
if (key && !this.imageErrorIds[key]) {
this.imageErrorIds = { ...this.imageErrorIds, [key]: true };
}
},
/** 从 nutrition / nutrients / nutritions / nutrientsJson 解析为列表项 */
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 = this.nutritionScalarToDisplayString(n.value);
if (u && val !== '—' && !val.endsWith(u)) val += u;
} else if (n.amount != null && n.amount !== '') {
val = this.nutritionScalarToDisplayString(n.amount);
if (u && val !== '—' && !val.endsWith(u)) val += u;
} else if (n.val != null && n.val !== '') {
val = this.nutritionScalarToDisplayString(n.val);
if (u && val !== '—' && !val.endsWith(u)) val += u;
} else {
val = '—';
}
return {
label: n.label || n.name || n.labelName || n.nutrientName || n.nutrient_name || n.key || n.text || '—',
value: val,
colorClass: n.colorClass || 'green'
};
};
const tryArr = (arr) => {
if (!Array.isArray(arr) || arr.length === 0) return null;
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;
try {
const j = JSON.parse(s);
if (Array.isArray(j) && j.length > 0) return j.map(mapNut);
if (j && typeof j === 'object' && !Array.isArray(j)) {
const keys = Object.keys(j);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: j[k] != null ? String(j[k]) : '—',
colorClass: 'green'
}));
}
} catch (e) { /* ignore */ }
return null;
};
/** 后端 nutrientsJson 可能被反序列化为对象/数组,不能仅按字符串解析 */
const nutrientsJsonValue = (it) => {
if (!it || typeof it !== 'object') return null;
const v = it.nutrientsJson != null ? it.nutrientsJson
: (it.nutrients_json != null ? it.nutrients_json
: (it.nutritionJson != null ? it.nutritionJson : it.nutrition_json));
return v != null && v !== '' ? v : null;
};
const fromNutrientsJson = (nj) => {
if (nj == null || nj === '') return null;
if (Array.isArray(nj) && nj.length > 0) return nj.map(mapNut);
if (typeof nj === 'object' && !Array.isArray(nj)) {
const keys = Object.keys(nj);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: nj[k] != null ? String(nj[k]) : '—',
colorClass: 'green'
}));
}
if (typeof nj === 'string') {
try {
const j = JSON.parse(nj);
if (Array.isArray(j) && j.length > 0) return j.map(mapNut);
if (j && typeof j === 'object' && !Array.isArray(j)) {
const keys = Object.keys(j);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: j[k] != null ? String(j[k]) : '—',
colorClass: 'green'
}));
}
} catch (e) { /* ignore */ }
}
return null;
};
let out = fromNutrientsJson(nutrientsJsonValue(item));
// 空数组 [] 在 JS 中为 truthy不能用 if (out),否则会跳过 nutrition/nutrients 等后备字段
if (out && out.length > 0) return out;
out = tryArr(item.nutrition);
if (out && out.length > 0) return out;
out = tryArr(item.nutrients);
if (out && out.length > 0) return out;
out = tryArr(item.nutritions);
if (out && out.length > 0) return out;
out = tryJsonString(item.nutrition);
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) {
const categoryNameMap = {
grain: '谷薯类', vegetable: '蔬菜类', fruit: '水果类',
meat: '肉蛋类', seafood: '水产类', dairy: '奶类',
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, 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++) {
item = this.shallowFillEmptyFields(item, nests[ni]);
}
}
if (!item || typeof item !== 'object') {
return {
name: '',
image: '',
imageUrl: '',
nutrition: [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
],
category: '',
safety: '—',
safetyClass: 'safe'
};
}
const safetyMap = {
suitable: { safety: '放心吃', safetyClass: 'safe' },
moderate: { safety: '限量吃', safetyClass: 'limited' },
restricted: { safety: '谨慎吃', safetyClass: 'careful' },
forbidden: { safety: '谨慎吃', safetyClass: 'careful' }
};
const suitabilityLevel = item.suitabilityLevel != null && item.suitabilityLevel !== ''
? item.suitabilityLevel
: item.suitability_level;
const safety = item.safety != null
? { safety: item.safety, safetyClass: item.safetyClass || 'safe' }
: (safetyMap[suitabilityLevel] || { safety: '—', safetyClass: 'safe' });
const rawStr = this.coalesceFoodImageField(item);
const validRaw = rawStr && rawStr !== 'null' && rawStr !== 'undefined';
const base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim()) ? String(HTTP_REQUEST_URL).replace(/\/$/, '') : '';
let image = '';
if (validRaw) {
if (rawStr.startsWith('data:')) image = rawStr;
else if (rawStr.startsWith('//') || rawStr.startsWith('http')) image = rawStr;
else if (rawStr.startsWith('/')) image = base ? base + rawStr : rawStr;
else image = base ? base + '/' + rawStr : rawStr;
}
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 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);
const p = item.protein != null ? item.protein : (item.proteinG != null ? item.proteinG : item.protein_g);
const k = item.potassium != null ? item.potassium : (item.potassiumMg != null ? item.potassiumMg : item.potassium_mg);
const ph = item.phosphorus != null ? item.phosphorus : (item.phosphorusMg != null ? item.phosphorusMg : item.phosphorus_mg);
const na = item.sodium != null ? item.sodium : (item.sodiumMg != null ? item.sodiumMg : item.sodium_mg);
const ca = item.calcium != null ? item.calcium : (item.calciumMg != null ? item.calciumMg : item.calcium_mg);
push('能量', e, 'kcal');
push('蛋白质', p, 'g');
push('钾', k, 'mg');
push('磷', ph, 'mg');
push('钠', na, 'mg');
push('钙', ca, 'mg');
}
if (!nutrition.length) {
nutrition = [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
}
// 与 goToFoodDetail 一致:在 id / foodId / food_id 等字段中找第一个纯数字 ID避免 id 被误填为名称时整条链路传错
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 n;
}
return '';
};
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整数字符串避免 parseInt/Number 丢精度);勿写 null
const idFields =
resolvedIdStr !== ''
? { id: resolvedIdStr, foodId: resolvedIdStr }
: {};
return {
...item,
...idFields,
name: resolvedName || item.name || '—',
image: image || '',
imageUrl: image || '',
img: image || '',
category,
safety: safety.safety,
safetyClass: safety.safetyClass,
nutrition: Array.isArray(nutrition) ? nutrition : [],
nutrients: Array.isArray(nutrition) ? nutrition : [],
nutritions: Array.isArray(nutrition) ? nutrition : [],
energy: item.energy,
protein: item.protein,
potassium: item.potassium,
phosphorus: item.phosphorus,
sodium: item.sodium,
calcium: item.calcium
};
},
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 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) => {
let r = row;
if (typeof r === 'string' && r.trim()) {
try {
r = JSON.parse(r);
} catch (e) { /* keep string */ }
}
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);
}
} else {
await this.loadFoodList();
}
}, 300);
},
/**
* 点击食物卡片的入口方法:通过 index 从 filteredFoodList 实时获取 item。
* 小程序编译时 @click="fn(item)" 会通过 dataset 传递复杂对象scroll-view 内
* DOM 回收或列表异步刷新后 dataset 可能丢失导致 item 为 undefined。
* 改为传递基本类型 index在方法内从响应式数据实时取值彻底解决此问题。
*/
handleFoodItemClick(index) {
const item = this.filteredFoodList[index]
if (!item || typeof item !== 'object') {
console.warn('[food-encyclopedia] handleFoodItemClick: index', index, '对应 item 为空filteredFoodList.length =', this.filteredFoodList.length)
return
}
this.goToFoodDetail(item)
},
goToFoodDetail(item) {
// 防御性校验(兜底,正常路径由 handleFoodItemClick 保证 item 有效)
if (!item || typeof item !== 'object') {
console.warn('[food-encyclopedia] goToFoodDetail: item 为空,跳过跳转', item)
return
}
// 后端详情接口仅接受 Long 类型 id与 getFoodDetail 共用规范化(支持 123.0 等,禁止名称进路径)
const pickNumericIdStr = () => {
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
}
return ''
}
const idStr = pickNumericIdStr()
const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
// 使用规范化后的整数字符串作为 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, name: item.name, url })
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;
min-height: 0;
overflow: hidden;
}
.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%;
display: block;
/* H5 使用原生 img 时与 image mode=aspectFill 一致 */
object-fit: cover;
}
/* 无图或加载失败时的占位按分类着色emoji 居中) */
.food-image-placeholder {
background: #f2f4f8;
display: flex;
align-items: center;
justify-content: center;
}
.food-image-placeholder .category-emoji {
font-size: 64rpx;
line-height: 1;
}
.warning-badge {
position: absolute;
top: 8rpx;
left: 8rpx;
background: #ff6464;
border-radius: 12rpx;
padding: 4rpx 12rpx;
font-size: 24rpx;
}
.food-info {
flex: 1;
min-width: 0;
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>