Files
msh-system/msh_single_uniapp/pages/tool/food-encyclopedia.vue
msh-agent dce899f655 fix: 测试反馈0403修改 — 百科Bug修复/份数→克数/AI对话增强/流式输出
1. [P0] food-encyclopedia: 修复 goToFoodDetail TypeError 报错
   - 增加 item 空值防御性校验
   - 加固 filteredFoodList 过滤无效项

2. [P1] calculator-result: 食物份数建议改为克数
   - 模板展示从"X份"改为"X克"
   - applyResult 数据适配:优先读 gram 字段,兜底 portion * gramPerServing 换算

3. [P2] ai-nutritionist: 新增消息操作按钮(复制/重新生成/删除)
   - AI消息气泡下方新增 msg-actions 按钮组
   - 复制到剪贴板、删除单条消息、重新生成最后一条AI回复

4. [P2] ai-nutritionist + models-api: 启用流式输出改善响应速度
   - 新增 kieaiGeminiChatStream 函数(SSE + enableChunked)
   - sendToAI 优先走流式,失败自动降级为非流式

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:20:10 +08:00

1280 lines
44 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 class="food-scroll" scroll-y>
<view class="food-list-container">
<view class="food-count"> {{ filteredFoodList.length }} 种食物</view>
<view class="food-list">
<view
class="food-item"
v-for="(item, index) in filteredFoodList"
:key="foodRowKey(item, index)"
@click="goToFoodDetail(item)"
>
<view class="food-image-wrapper">
<!-- 配图优先 item.imageUrl/img/imagenormalize 已对齐再经 displayFoodImage 拼域名与占位BUG-003 -->
<!-- #ifdef H5 -->
<img
class="food-image"
:class="{ 'food-image-placeholder': !hasFoodImage(item, index) }"
:src="displayFoodImage(item, index)"
alt=""
@error="onFoodImageError(item, index, $event)"
/>
<!-- #endif -->
<!-- #ifndef H5 -->
<image
class="food-image"
:class="{ 'food-image-placeholder': !hasFoodImage(item, index) }"
:src="displayFoodImage(item, index)"
mode="aspectFill"
@error="onFoodImageError(item, index, $event)"
></image>
<!-- #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 displayNutritionList(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,
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'
}
]
}
},
computed: {
filteredFoodList() {
const list = (this.foodList || []).filter(item => item != null && typeof item === 'object' && item.name);
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));
},
},
onLoad(options) {
if (options && options.category) {
this.currentCategory = options.category;
}
this.loadFoodList();
},
methods: {
/** 列表 :key无效 id 曾为 '' 时多行共用同一 key导致点击与展示错乱 */
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
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
});
let rawList = this.getRawFoodList(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 会兜底 */ }
}
return this.normalizeFoodItem(r);
}).filter(i => i != null);
} catch (error) {
console.error('加载食物列表失败:', error);
}
},
// 兼容 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;
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 为 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;
}
// 再包一层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 (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;
}
},
/** 部分网关/旧接口把单条包在 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', '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 };
}
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;
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;
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 = [
'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
'nutrientVoList', 'nutrient_vo_list',
'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 字段等
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.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.mainImage, item.main_image,
item.foodImg, item.food_img, item.logo, item.avatar, item.banner,
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';
}
},
hasFoodImage(item, index) {
const url = this.getFoodImage(item, index);
return !!(url && String(url).trim());
},
/** 须始终非空键,否则 @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 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 等与列表接口对象形态)。
*/
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 = rawValue != null && rawValue !== '' ? String(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' }
];
}
},
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',
'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',
'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 numStr = (rawValue != null && rawValue !== '') ? String(rawValue) : '';
const value = numStr !== '' ? (unit && !numStr.endsWith(unit) ? numStr + unit : numStr) : '—';
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) => {
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;
} else if (n.amount != null && n.amount !== '') {
val = String(n.amount) + u;
} else if (n.val != null && n.val !== '') {
val = String(n.val);
if (u && !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;
return arr.map(mapNut);
};
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;
return null;
},
normalizeFoodItem(raw) {
const categoryNameMap = {
grain: '谷薯类', vegetable: '蔬菜类', fruit: '水果类',
meat: '肉蛋类', seafood: '水产类', dairy: '奶类',
bean: '豆类', nut: '坚果类', all: '全部'
};
let item = this.unwrapFoodListRow(raw);
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)
);
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 = this.parseNutrientsFromItem(item);
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 || '') : '—';
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 pickFirstNumericFoodId = (it) => {
const cands = [it.id, it.foodId, it.food_id, it.v2FoodId, it.v2_food_id, it.foodID];
for (let i = 0; i < cands.length; i++) {
const n = normalizeFoodDetailIdString(cands[i]);
if (n !== '') return parseInt(n, 10);
}
return null;
};
const resolvedId = pickFirstNumericFoodId(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 解析
const idFields =
resolvedId !== null && resolvedId !== undefined && !Number.isNaN(resolvedId)
? { id: resolvedId, foodId: resolvedId }
: {};
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
});
let rawList = this.getRawFoodList(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 */ }
}
return this.normalizeFoodItem(r);
}).filter(i => i != null);
} catch (error) {
console.error('搜索失败:', error);
}
} else {
await this.loadFoodList();
}
}, 300);
},
goToFoodDetail(item) {
// 防御性校验:避免 item 为空时报错fix: TypeError Cannot read property 'id' of undefined
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.v2FoodId, item.v2_food_id, item.foodID]
for (let i = 0; i < cands.length; i++) {
const n = normalizeFoodDetailIdString(cands[i])
if (n !== '') return n
}
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}`
: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
console.log('[food-encyclopedia] goToFoodDetail 跳转参数:', { idStr, numericId, 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;
overflow-y: auto;
}
.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;
}
/* 无图或加载失败时的占位(灰色背景,与 wrapper 一致) */
.food-image-placeholder {
background: #e4e5e7;
}
.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>