diff --git a/msh_single_uniapp/pages/tool/food-encyclopedia.vue b/msh_single_uniapp/pages/tool/food-encyclopedia.vue index 6a64352..574285a 100644 --- a/msh_single_uniapp/pages/tool/food-encyclopedia.vue +++ b/msh_single_uniapp/pages/tool/food-encyclopedia.vue @@ -91,10 +91,25 @@ - - + + 共 {{ filteredFoodList.length }} 种食物 + + + + + + + + + 加载中… + + + + + - + + @@ -140,7 +156,7 @@ {{ nut.label || '—' }} @@ -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 {