2026-02-28 05:40:21 +08:00
|
|
|
|
<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">
|
2026-03-25 14:54:31 +08:00
|
|
|
|
<view
|
|
|
|
|
|
class="food-item"
|
|
|
|
|
|
v-for="(item, index) in filteredFoodList"
|
2026-04-11 15:20:10 +08:00
|
|
|
|
:key="foodRowKey(item, index)"
|
2026-02-28 05:40:21 +08:00
|
|
|
|
@click="goToFoodDetail(item)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<view class="food-image-wrapper">
|
2026-04-11 15:20:10 +08:00
|
|
|
|
<!-- 配图:优先 item.imageUrl/img/image(normalize 已对齐),再经 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 -->
|
2026-03-04 12:21:29 +08:00
|
|
|
|
<image
|
|
|
|
|
|
class="food-image"
|
2026-04-11 15:20:10 +08:00
|
|
|
|
:class="{ 'food-image-placeholder': !hasFoodImage(item, index) }"
|
|
|
|
|
|
:src="displayFoodImage(item, index)"
|
2026-03-04 12:21:29 +08:00
|
|
|
|
mode="aspectFill"
|
2026-04-11 15:20:10 +08:00
|
|
|
|
@error="onFoodImageError(item, index, $event)"
|
2026-03-04 12:21:29 +08:00
|
|
|
|
></image>
|
2026-04-11 15:20:10 +08:00
|
|
|
|
<!-- #endif -->
|
2026-02-28 05:40:21 +08:00
|
|
|
|
<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>
|
2026-03-03 15:33:50 +08:00
|
|
|
|
<view class="safety-tag" :class="item.safetyClass || 'safe'">
|
|
|
|
|
|
<text>{{ item.safety || '—' }}</text>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
2026-03-30 12:46:24 +08:00
|
|
|
|
<!-- <view v-if="item.category" class="category-badge">
|
2026-02-28 05:40:21 +08:00
|
|
|
|
<text>{{ item.category }}</text>
|
2026-03-30 12:46:24 +08:00
|
|
|
|
</view> -->
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
<view class="nutrition-list">
|
2026-04-11 15:20:10 +08:00
|
|
|
|
<!-- 营养简介:nutrition / nutrients / nutritions / nutrientsJson + 扁平 energy 等,见 displayNutritionList(BUG-003) -->
|
2026-02-28 05:40:21 +08:00
|
|
|
|
<view
|
|
|
|
|
|
class="nutrition-item"
|
2026-04-11 15:20:10 +08:00
|
|
|
|
v-for="(nut, idx) in displayNutritionList(item)"
|
|
|
|
|
|
:key="'n-' + foodRowKey(item, index) + '-' + idx"
|
2026-02-28 05:40:21 +08:00
|
|
|
|
>
|
2026-03-05 09:35:00 +08:00
|
|
|
|
<text class="nutrition-label">{{ nut.label || '—' }}</text>
|
|
|
|
|
|
<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value != null ? nut.value : '—' }}</text>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</scroll-view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-03-03 15:33:50 +08:00
|
|
|
|
import { HTTP_REQUEST_URL } from '@/config/app.js';
|
2026-04-11 15:20:10 +08:00
|
|
|
|
import { normalizeFoodDetailIdString } from '@/api/tool.js';
|
2026-03-03 15:33:50 +08:00
|
|
|
|
|
2026-02-28 05:40:21 +08:00
|
|
|
|
export default {
|
|
|
|
|
|
data() {
|
2026-04-11 15:20:10 +08:00
|
|
|
|
// 本地占位图(小程序/H5 均可用;勿用 data: URL,部分端 image 组件不展示)
|
|
|
|
|
|
const defaultPlaceholder = '/static/images/food-placeholder.svg';
|
2026-02-28 05:40:21 +08:00
|
|
|
|
return {
|
|
|
|
|
|
searchText: '',
|
|
|
|
|
|
currentCategory: 'all',
|
|
|
|
|
|
searchTimer: null,
|
2026-03-03 15:33:50 +08:00
|
|
|
|
defaultPlaceholder,
|
2026-03-04 12:21:29 +08:00
|
|
|
|
imageErrorIds: {}, // 图片加载失败时用占位图,key 为 item.id
|
2026-02-28 05:40:21 +08:00
|
|
|
|
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() {
|
2026-04-11 15:20:10 +08:00
|
|
|
|
const list = (this.foodList || []).filter(item => item != null && typeof item === 'object' && item.name);
|
2026-03-25 14:54:31 +08:00
|
|
|
|
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));
|
2026-02-28 05:40:21 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
onLoad(options) {
|
|
|
|
|
|
if (options && options.category) {
|
|
|
|
|
|
this.currentCategory = options.category;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.loadFoodList();
|
|
|
|
|
|
},
|
|
|
|
|
|
methods: {
|
2026-04-11 15:20:10 +08:00
|
|
|
|
/** 列表 :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')
|
|
|
|
|
|
},
|
2026-02-28 05:40:21 +08:00
|
|
|
|
async loadFoodList() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { getFoodList } = await import('@/api/tool.js');
|
|
|
|
|
|
const result = await getFoodList({
|
|
|
|
|
|
category: this.currentCategory === 'all' ? '' : this.currentCategory,
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
limit: 100
|
|
|
|
|
|
});
|
2026-04-11 15:20:10 +08:00
|
|
|
|
let rawList = this.getRawFoodList(result);
|
|
|
|
|
|
if (!Array.isArray(rawList)) rawList = [];
|
2026-03-04 12:21:29 +08:00
|
|
|
|
this.imageErrorIds = {};
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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);
|
2026-02-28 05:40:21 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载食物列表失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-11 15:20:10 +08:00
|
|
|
|
// 兼容 CommonResult.data 为 CommonPage、网关再包一层 data、或直接数组等(与 nutrition-knowledge extractKnowledgeListFromResponse 对齐)
|
2026-03-04 12:21:29 +08:00
|
|
|
|
getRawFoodList(result) {
|
2026-04-11 15:20:10 +08:00
|
|
|
|
if (result == null) return [];
|
|
|
|
|
|
if (typeof result === 'string' && result.trim()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return this.getRawFoodList(JSON.parse(result));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 18:56:53 +08:00
|
|
|
|
if (Array.isArray(result)) return result;
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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.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) {
|
|
|
|
|
|
const inner = this.parsePageListIfString(result.data.data);
|
|
|
|
|
|
if (Array.isArray(inner.list)) return inner.list;
|
|
|
|
|
|
}
|
2026-03-04 12:21:29 +08:00
|
|
|
|
const page = result.data !== undefined && result.data !== null ? result.data : result;
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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;
|
2026-03-04 12:21:29 +08:00
|
|
|
|
return [];
|
|
|
|
|
|
},
|
2026-04-11 15:20:10 +08:00
|
|
|
|
/** 分页对象里 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);
|
2026-03-09 18:56:53 +08:00
|
|
|
|
const s = (raw != null && String(raw).trim()) ? String(raw).trim() : '';
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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.nutrition(BUG-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/value(name/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' }
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
2026-03-03 15:33:50 +08:00
|
|
|
|
},
|
2026-03-07 22:26:37 +08:00
|
|
|
|
getNutritionList(item) {
|
|
|
|
|
|
if (!item) return [];
|
2026-04-11 15:20:10 +08:00
|
|
|
|
// 空数组在 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;
|
2026-03-07 22:26:37 +08:00
|
|
|
|
const list = [];
|
|
|
|
|
|
const push = (label, val, unit) => {
|
2026-04-11 15:20:10 +08:00
|
|
|
|
const num = val != null && val !== '' && typeof val === 'object' && 'valueOf' in val ? val.valueOf() : val;
|
|
|
|
|
|
const value = (num != null && num !== '') ? String(num) + (unit || '') : '—';
|
2026-03-07 22:26:37 +08:00
|
|
|
|
list.push({ label, value, colorClass: 'green' });
|
|
|
|
|
|
};
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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' }];
|
2026-03-07 22:26:37 +08:00
|
|
|
|
},
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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 };
|
2026-03-04 12:21:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-11 15:20:10 +08:00
|
|
|
|
/** 从 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) {
|
2026-03-25 14:54:31 +08:00
|
|
|
|
const categoryNameMap = {
|
|
|
|
|
|
grain: '谷薯类', vegetable: '蔬菜类', fruit: '水果类',
|
|
|
|
|
|
meat: '肉蛋类', seafood: '水产类', dairy: '奶类',
|
|
|
|
|
|
bean: '豆类', nut: '坚果类', all: '全部'
|
|
|
|
|
|
};
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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'
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-03 15:33:50 +08:00
|
|
|
|
const safetyMap = {
|
|
|
|
|
|
suitable: { safety: '放心吃', safetyClass: 'safe' },
|
|
|
|
|
|
moderate: { safety: '限量吃', safetyClass: 'limited' },
|
|
|
|
|
|
restricted: { safety: '谨慎吃', safetyClass: 'careful' },
|
|
|
|
|
|
forbidden: { safety: '谨慎吃', safetyClass: 'careful' }
|
|
|
|
|
|
};
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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' });
|
2026-03-03 15:33:50 +08:00
|
|
|
|
|
2026-04-11 15:20:10 +08:00
|
|
|
|
const rawStr = this.coalesceFoodImageField(item);
|
2026-03-07 22:26:37 +08:00
|
|
|
|
const validRaw = rawStr && rawStr !== 'null' && rawStr !== 'undefined';
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-03 15:33:50 +08:00
|
|
|
|
|
2026-04-11 15:20:10 +08:00
|
|
|
|
let nutrition = this.parseNutrientsFromItem(item);
|
|
|
|
|
|
if (!nutrition || nutrition.length === 0) {
|
2026-03-03 15:33:50 +08:00
|
|
|
|
nutrition = [];
|
2026-03-05 09:35:00 +08:00
|
|
|
|
const push = (label, val, unit) => {
|
2026-04-11 15:20:10 +08:00
|
|
|
|
const num = val != null && val !== '' && typeof val === 'object' && 'valueOf' in val ? val.valueOf() : val;
|
|
|
|
|
|
const value = (num != null && num !== '') ? String(num) + (unit || '') : '—';
|
2026-03-05 09:35:00 +08:00
|
|
|
|
nutrition.push({ label, value, colorClass: 'green' });
|
|
|
|
|
|
};
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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' }
|
|
|
|
|
|
];
|
2026-03-03 15:33:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 15:20:10 +08:00
|
|
|
|
// 与 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);
|
2026-03-25 14:54:31 +08:00
|
|
|
|
const rawCategory = item.category || item.categoryName || item.category_name || '';
|
|
|
|
|
|
const category = categoryNameMap[rawCategory] || rawCategory;
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
: {};
|
2026-03-03 15:33:50 +08:00
|
|
|
|
return {
|
|
|
|
|
|
...item,
|
2026-04-11 15:20:10 +08:00
|
|
|
|
...idFields,
|
|
|
|
|
|
name: resolvedName || item.name || '—',
|
2026-03-09 18:56:53 +08:00
|
|
|
|
image: image || '',
|
|
|
|
|
|
imageUrl: image || '',
|
2026-04-11 15:20:10 +08:00
|
|
|
|
img: image || '',
|
2026-03-25 14:54:31 +08:00
|
|
|
|
category,
|
2026-03-03 15:33:50 +08:00
|
|
|
|
safety: safety.safety,
|
|
|
|
|
|
safetyClass: safety.safetyClass,
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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
|
2026-03-03 15:33:50 +08:00
|
|
|
|
};
|
|
|
|
|
|
},
|
2026-02-28 05:40:21 +08:00
|
|
|
|
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
|
|
|
|
|
|
});
|
2026-04-11 15:20:10 +08:00
|
|
|
|
let rawList = this.getRawFoodList(result);
|
|
|
|
|
|
if (!Array.isArray(rawList)) rawList = [];
|
2026-03-04 12:21:29 +08:00
|
|
|
|
this.imageErrorIds = {};
|
2026-04-11 15:20:10 +08:00
|
|
|
|
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);
|
2026-02-28 05:40:21 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('搜索失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await this.loadFoodList();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
},
|
|
|
|
|
|
goToFoodDetail(item) {
|
2026-04-11 15:20:10 +08:00
|
|
|
|
// 防御性校验:避免 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
|
2026-03-03 15:33:50 +08:00
|
|
|
|
const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
|
2026-04-11 15:20:10 +08:00
|
|
|
|
const url = (numericId !== null && typeof numericId === 'number' && !isNaN(numericId))
|
2026-03-03 15:33:50 +08:00
|
|
|
|
? `/pages/tool/food-detail?id=${numericId}${namePart}`
|
|
|
|
|
|
: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
|
2026-04-11 15:20:10 +08:00
|
|
|
|
console.log('[food-encyclopedia] goToFoodDetail 跳转参数:', { idStr, numericId, name: item.name, url })
|
2026-03-03 15:33:50 +08:00
|
|
|
|
uni.navigateTo({ url })
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</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%;
|
2026-04-11 15:20:10 +08:00
|
|
|
|
display: block;
|
|
|
|
|
|
/* H5 使用原生 img 时与 image mode=aspectFill 一致 */
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 无图或加载失败时的占位(灰色背景,与 wrapper 一致) */
|
|
|
|
|
|
.food-image-placeholder {
|
|
|
|
|
|
background: #e4e5e7;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.warning-badge {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 8rpx;
|
|
|
|
|
|
left: 8rpx;
|
|
|
|
|
|
background: #ff6464;
|
|
|
|
|
|
border-radius: 12rpx;
|
|
|
|
|
|
padding: 4rpx 12rpx;
|
|
|
|
|
|
font-size: 24rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.food-info {
|
|
|
|
|
|
flex: 1;
|
2026-04-11 15:20:10 +08:00
|
|
|
|
min-width: 0;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
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>
|