<template>
	<view class="food-page">
		<!-- 搜索框 -->
		<view class="search-container">
			<view class="search-box">
				<text class="search-icon">🔍</text>
				<input 
					class="search-input" 
					v-model="searchText" 
					placeholder="搜索食物名称..."
					placeholder-style="color: #9fa5c0"
					@input="handleSearch"
				/>
			</view>
		</view>

		<!-- 分类标签 -->
		<scroll-view class="category-scroll" scroll-x>
			<view class="category-list">
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'all' }"
					@click="selectCategory('all')"
				>
					<text>全部</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'grain' }"
					@click="selectCategory('grain')"
				>
					<text class="category-icon">🌾</text>
					<text>谷薯类</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'vegetable' }"
					@click="selectCategory('vegetable')"
				>
					<text class="category-icon">🥬</text>
					<text>蔬菜类</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'fruit' }"
					@click="selectCategory('fruit')"
				>
					<text class="category-icon">🍎</text>
					<text>水果类</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'meat' }"
					@click="selectCategory('meat')"
				>
					<text class="category-icon">🍖</text>
					<text>肉蛋类</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'seafood' }"
					@click="selectCategory('seafood')"
				>
					<text class="category-icon">🐟</text>
					<text>水产类</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'dairy' }"
					@click="selectCategory('dairy')"
				>
					<text class="category-icon">🥛</text>
					<text>奶类</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'bean' }"
					@click="selectCategory('bean')"
				>
					<text class="category-icon">🫘</text>
					<text>豆类</text>
				</view>
				<view 
					class="category-item" 
					:class="{ active: currentCategory === 'nut' }"
					@click="selectCategory('nut')"
				>
					<text class="category-icon">🥜</text>
					<text>坚果类</text>
				</view>
			</view>
		</scroll-view>

		<!-- 食物列表：小程序 scroll-view 需明确高度；enable-flex 保证内部 .food-item 的 flex 横向布局正常（BUG-003） -->
		<scroll-view class="food-scroll" scroll-y :enable-flex="true" :style="{ height: foodScrollHeight }">
			<view class="food-list-container">
				<view class="food-count">共 {{ filteredFoodList.length }} 种食物</view>
				<!-- 加载中：先用骨架占位，避免 mock 数据闪烁（test-0415 反馈3：列表页面闪烁） -->
				<view v-if="!foodLoaded && filteredFoodList.length === 0" class="food-list">
					<view v-for="i in 4" :key="'sk' + i" class="food-item">
						<view class="food-image-wrapper">
							<view class="food-image food-image-placeholder" aria-hidden="true" />
						</view>
						<view class="food-info">
							<view class="food-header">
								<view class="food-name-wrapper">
									<text class="food-name" style="opacity:.4">加载中…</text>
								</view>
							</view>
						</view>
					</view>
				</view>
				<view class="food-list">
					<view
						class="food-item"
						v-for="(item, index) in filteredFoodList"
						:key="foodRowKey(item, index)"
						@click="handleFoodItemClick(index)"
					>
						<view class="food-image-wrapper">
							<!-- 配图：H5 始终用 img + src；小程序无远程图时用按分类着色的 emoji 占位（test-0415 反馈4-2） -->
							<!-- #ifdef H5 -->
							<img
								v-if="hasRemoteFoodImageSrc(item, index)"
								class="food-image"
								:src="resolvedFoodImage(item, index)"
								alt=""
								@error="onFoodImageError(item, index, $event)"
							/>
							<view
								v-else
								class="food-image food-image-placeholder"
								:style="{ backgroundColor: categoryPlaceholderBg(item) }"
								aria-hidden="true"
							>
								<text class="category-emoji">{{ categoryPlaceholderEmoji(item) }}</text>
							</view>
							<!-- #endif -->
							<!-- #ifndef H5 -->
							<image
								v-if="hasRemoteFoodImageSrc(item, index)"
								class="food-image"
								:src="remoteFoodImageUrl(item, index)"
								mode="aspectFill"
								@error="onFoodImageError(item, index, $event)"
							></image>
							<view
								v-else
								class="food-image food-image-placeholder"
								:style="{ backgroundColor: categoryPlaceholderBg(item) }"
								aria-hidden="true"
							>
								<text class="category-emoji">{{ categoryPlaceholderEmoji(item) }}</text>
							</view>
							<!-- #endif -->
							<view v-if="item.warning" class="warning-badge">⚠️</view>
						</view>
						<view class="food-info">
							<view class="food-header">
								<view class="food-name-wrapper">
									<text class="food-name">{{ item.name }}</text>
									<view class="safety-tag" :class="item.safetyClass || 'safe'">
										<text>{{ item.safety || '—' }}</text>
									</view>
								</view>
		<!-- 						<view v-if="item.category" class="category-badge">
									<text>{{ item.category }}</text>
								</view> -->
							</view>
							<view class="nutrition-list">
								<!-- 营养简介：nutrition / nutrients / nutritions / nutrientsJson + 扁平 energy 等，见 displayNutritionList（BUG-003） -->
								<view 
									class="nutrition-item" 
									v-for="(nut, idx) in resolvedNutritionList(item)" 
									:key="'n-' + foodRowKey(item, index) + '-' + idx"
								>
									<text class="nutrition-label">{{ nut.label || '—' }}</text>
									<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value != null ? nut.value : '—' }}</text>
								</view>
							</view>
						</view>
					</view>
				</view>
			</view>
		</scroll-view>
	</view>
</template>

<script>
import { HTTP_REQUEST_URL } from '@/config/app.js';
import { normalizeFoodDetailIdString } from '@/api/tool.js';

export default {
	data() {
		// 本地占位图（小程序/H5 均可用；勿用 data: URL，部分端 image 组件不展示）
		const defaultPlaceholder = '/static/images/food-placeholder.svg';
		return {
			searchText: '',
			currentCategory: 'all',
			searchTimer: null,
			defaultPlaceholder,
			/** 列表区 scroll-view 高度（小程序须 px，不能用仅靠 flex:1）（BUG-003） */
			foodScrollHeight: '100vh',
			imageErrorIds: {}, // 图片加载失败时用占位图，key 为 item.id
			foodLoaded: false, // API 是否已返回（避免初次进入闪烁 mock 数据）
			foodList: []
		}
	},
	computed: {
		filteredFoodList() {
			const list = (this.foodList || []).filter((item) => {
				if (item == null || typeof item !== 'object') return false;
				const n = item.name || item.foodName || item.food_name || item.title;
				return n != null && String(n).trim() !== '';
			});
			if (!this.searchText || !this.searchText.trim()) return list;
			const kw = this.searchText.trim().toLowerCase();
			return list.filter((item) => {
				const raw = item && (item.name != null ? item.name : (item.foodName != null ? item.foodName : (item.food_name != null ? item.food_name : item.title)));
				const nameStr = raw != null ? String(raw).toLowerCase() : '';
				return nameStr.includes(kw);
			});
		},
	},
	onLoad(options) {
		if (options && options.category) {
			this.currentCategory = options.category;
		}
		this.loadFoodList();
	},
	onReady() {
		this.updateFoodScrollHeight();
	},
	methods: {
		/** 根据搜索区 + 分类条占位，计算下方列表 scroll-view 高度（与 nutrition-knowledge / dietary-records 一致，BUG-003） */
		updateFoodScrollHeight() {
			this.$nextTick(() => {
				try {
					const sys = uni.getSystemInfoSync();
					const winH = typeof sys.windowHeight === 'number' ? sys.windowHeight : 667;
					const q = uni.createSelectorQuery().in(this);
					q.select('.search-container').boundingClientRect();
					q.select('.category-scroll').boundingClientRect();
					q.exec((rects) => {
						const searchRect = rects && rects[0];
						const catRect = rects && rects[1];
						let bottom = 0;
						if (catRect && typeof catRect.bottom === 'number') {
							bottom = catRect.bottom;
						} else if (searchRect && typeof searchRect.bottom === 'number') {
							bottom = searchRect.bottom;
						}
						const pad = typeof uni.upx2px === 'function' ? uni.upx2px(16) : 8;
						const minH = typeof uni.upx2px === 'function' ? uni.upx2px(320) : 160;
						const h = Math.floor(winH - bottom - pad);
						this.foodScrollHeight = `${Math.max(minH, h)}px`;
					});
				} catch (e) {
					this.foodScrollHeight = 'calc(100vh - 280rpx)';
				}
			});
		},
		/** 列表 :key：始终拼接 index 保证唯一，避免重复 id 导致 Vue DOM 复用错乱、点击传参丢失 */
		foodRowKey(item, index) {
			const id = item && item.id
			const idStr = id !== undefined && id !== null && id !== '' ? String(id).trim() : ''
			if (idStr !== '' && /^-?\d+$/.test(idStr)) return 'id-' + idStr + '-' + index
			const n = item && item.name != null ? String(item.name).trim() : ''
			return 'idx-' + index + '-' + (n || 'row')
		},
		async loadFoodList() {
			try {
				const { getFoodList } = await import('@/api/tool.js');
				const result = await getFoodList({
					category: this.currentCategory === 'all' ? '' : this.currentCategory,
					page: 1,
					limit: 100
				});
				const code = result && (result.code != null ? Number(result.code) : 200);
				if (Number.isFinite(code) && code !== 200) {
					const errMsg = (result && (result.message || result.msg)) || '加载食物列表失败';
					throw new Error(errMsg);
				}
				let rawList = this.getRawFoodList(this.unwrapFoodListApiResult(result));
				if (!Array.isArray(rawList)) rawList = [];
				this.imageErrorIds = {};
				this.foodList = (rawList || []).map((row) => {
					let r = row;
					if (typeof r === 'string' && r.trim()) {
						try {
							r = JSON.parse(r);
						} catch (e) { /* keep string, normalize 会兜底 */ }
					}
					try {
						return this.normalizeFoodItem(r);
					} catch (e) {
						console.warn('[food-encyclopedia] normalizeFoodItem 单行失败，已跳过该行', e);
						return null;
					}
				}).filter(i => i != null);
				this.$nextTick(() => this.updateFoodScrollHeight());
			} catch (error) {
				console.error('加载食物列表失败:', error);
			} finally {
				this.foodLoaded = true;
			}
		},
		/**
		 * 网关偶发把 CommonResult 再包一层：{ code, data: { code, data: CommonPage } }；
		 * 解包后再走 getRawFoodList，与 nutrition-knowledge unwrapKnowledgeListApiResult 一致（BUG-003）。
		 */
		unwrapFoodListApiResult(result) {
			if (result == null || typeof result !== 'object') {
				return result;
			}
			let cur = result;
			for (let i = 0; i < 3; i++) {
				const inner = cur.data;
				if (inner != null && typeof inner === 'object' && !Array.isArray(inner)
					&& inner.code != null && inner.code == 200
					&& inner.data != null && typeof inner.data === 'object' && !Array.isArray(inner.data)) {
					cur = inner;
					continue;
				}
				break;
			}
			return cur;
		},
		// 兼容 CommonResult.data 为 CommonPage、网关再包一层 data、或直接数组等（与 nutrition-knowledge extractKnowledgeListFromResponse 对齐）
		getRawFoodList(result) {
			if (result == null) return [];
			if (typeof result === 'string' && result.trim()) {
				try {
					return this.getRawFoodList(JSON.parse(result));
				} catch (e) {
					return [];
				}
			}
			if (Array.isArray(result)) return result;
			// 少数网关把列表放在根级 records/rows（与 data.list 并列）
			if (typeof result === 'object' && !Array.isArray(result)) {
				if (Array.isArray(result.records)) return result.records;
				if (Array.isArray(result.rows)) return result.rows;
				if (Array.isArray(result.foodList)) return result.foodList;
				if (Array.isArray(result.list)) return result.list;
			}
			if (typeof result === 'object' && typeof result.data === 'string' && result.data.trim()) {
				try {
					return this.getRawFoodList({ ...result, data: JSON.parse(result.data) });
				} catch (e) {
					/* ignore */
				}
			}
			// 少数网关/旧版直接把列表放在 data（数组）上
			if (Array.isArray(result.data)) {
				return result.data;
			}
			// CommonResult.data.data 为直接数组（双包一层，与 nutrition-knowledge 对齐）
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.data)) {
				return result.data.data;
			}
			// 双包 CommonResult：data 内仍为 { code, data: CommonPage }
			if (typeof result === 'object' && result.data != null && typeof result.data === 'object'
				&& result.data.data != null && typeof result.data.data === 'object'
				&& !Array.isArray(result.data.data)
				&& (result.data.data.list != null || Array.isArray(result.data.data.records) || Array.isArray(result.data.data.rows))) {
				return this.getRawFoodList({ ...result, data: result.data.data });
			}
			// CommonResult.data 为 CommonPage 时 list 在 data.list
			if (result.data != null && typeof result.data === 'object' && result.data.list != null) {
				const page = this.parsePageListIfString(result.data);
				if (Array.isArray(page.list)) return page.list;
			}
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.records)) {
				return result.data.records;
			}
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.rows)) {
				return result.data.rows;
			}
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.foodList)) {
				return result.data.foodList;
			}
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.content)) {
				return result.data.content;
			}
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.items)) {
				return result.data.items;
			}
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.foods)) {
				return result.data.foods;
			}
			if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.result)) {
				return result.data.result;
			}
			// 再包一层：CommonResult.data.data.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;
			}
			const page = result.data !== undefined && result.data !== null ? result.data : result;
			const pick = (p) => {
				if (p == null || typeof p !== 'object' || Array.isArray(p)) return null;
				if (p.data && Array.isArray(p.data)) return p.data;
				const pNorm = this.parsePageListIfString(p);
				if (Array.isArray(pNorm.list)) return pNorm.list;
				if (Array.isArray(p.records)) return p.records;
				if (Array.isArray(p.rows)) return p.rows;
				if (Array.isArray(p.results)) return p.results;
				if (Array.isArray(p.content)) return p.content;
				if (Array.isArray(p.items)) return p.items;
				if (Array.isArray(p.foods)) return p.foods;
				if (Array.isArray(p.result)) return p.result;
				if (p.data != null && typeof p.data === 'object' && !Array.isArray(p.data) && p.data.list != null) {
					const dNorm = this.parsePageListIfString(p.data);
					if (Array.isArray(dNorm.list)) return dNorm.list;
				}
				if (p.data && Array.isArray(p.data.list)) return p.data.list;
				if (p.data && Array.isArray(p.data.records)) return p.data.records;
				return null;
			};
			let list = pick(page);
			if (list) return list;
			if (page != null && typeof page === 'object' && !Array.isArray(page) && page.data != null && typeof page.data === 'object' && !Array.isArray(page.data)) {
				list = pick(page.data);
				if (list) return list;
			}
			if (Array.isArray(page)) return page;
			if (result && typeof result === 'object' && result.list != null) {
				const rNorm = this.parsePageListIfString(result);
				if (Array.isArray(rNorm.list)) return rNorm.list;
			}
			if (result && Array.isArray(result.records)) return result.records;
			if (result.data && Array.isArray(result.data)) return result.data;
			return [];
		},
		/** 分页对象里 list 偶发为 JSON 字符串；解析失败则原样返回 */
		parsePageListIfString(page) {
			if (page == null || typeof page !== 'object' || Array.isArray(page)) return page;
			const L = page.list;
			if (typeof L !== 'string' || !L.trim()) return page;
			try {
				const parsed = JSON.parse(L);
				return Array.isArray(parsed) ? { ...page, list: parsed } : page;
			} catch (e) {
				return page;
			}
		},
		/**
		 * unwrap 合并 { ...base, ...inner } 后，若 inner 用空值盖掉了 base 上的配图/营养字段，则从 base 恢复（BUG-003）。
		 */
		restoreNonEmptyFoodMediaFields(merged, base) {
			if (!merged || typeof merged !== 'object' || Array.isArray(merged)) return;
			if (!base || typeof base !== 'object' || Array.isArray(base)) return;
			const strEmpty = (v) => v == null || v === '' || String(v).trim() === '' || String(v) === 'null' || String(v) === 'undefined';
			const arrEmpty = (v) => !Array.isArray(v) || v.length === 0;
			const isEmptyMediaVal = (v) => {
				if (Array.isArray(v)) return arrEmpty(v);
				if (v && typeof v === 'object') return Object.keys(v).length === 0;
				return strEmpty(v);
			};
			const keys = [
				'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
				'foodPicture', 'food_picture', 'foodImageUrl', 'food_image_url',
				'coverImage', 'cover_image', 'coverUrl', 'cover_url',
				'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
				'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
				'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
				'energy', 'protein', 'potassium', 'phosphorus', 'sodium', 'calcium', 'fat', 'carbohydrate', 'carbs'
			];
			for (let i = 0; i < keys.length; i++) {
				const k = keys[i];
				if (!(k in base)) continue;
				const bv = base[k];
				if (isEmptyMediaVal(bv)) continue;
				if (!isEmptyMediaVal(merged[k])) continue;
				merged[k] = bv;
			}
		},
		/** 部分网关/旧接口把单条包在 food、record、info、item、data 下 */
		unwrapFoodListRow(raw) {
			if (raw == null) return raw;
			if (typeof raw === 'string' && raw.trim()) {
				try {
					return this.unwrapFoodListRow(JSON.parse(raw));
				} catch (e) {
					return raw;
				}
			}
			if (typeof raw !== 'object' || Array.isArray(raw)) return raw;
			const hasFoodShape = (o) => {
				if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
				const nameOk = o.name != null && String(o.name).trim() !== '';
				const idOk = (o.id != null && o.id !== '') || (o.foodId != null && o.foodId !== '') || (o.food_id != null && o.food_id !== '');
				const nutOk = ['image', 'imageUrl', 'image_url', 'foodImageUrl', 'food_image_url', 'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json', 'energy', 'protein', 'potassium', 'phosphorus', 'calcium', 'sodium'].some(
					(k) => o[k] != null && o[k] !== ''
				);
				return nameOk || idOk || nutOk;
			};
			let base = raw;
			if (!hasFoodShape(raw) && raw.data != null && typeof raw.data === 'object' && !Array.isArray(raw.data) && hasFoodShape(raw.data)) {
				base = { ...raw, ...raw.data };
				// data 子对象若带空 image/nutrients，会盖掉外层非空字段（与 inner 合并同类问题，BUG-003）
				this.restoreNonEmptyFoodMediaFields(base, raw);
			}
			const inner = base.food || base.foodVo || base.foodVO || base.foodDto || base.foodDTO
				|| base.v2Food || base.v2_food || base.V2Food || base.toolFood || base.tool_food
				|| base.record || base.info || base.detail || base.vo || base.item || base.row || base.entity;
			if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
				// 以 inner 为主，避免外层空字段（如 image/nutrients 为空）覆盖内层真实数据
				// 同时把外层标识字段补齐回结果（如 id、foodId 等）
				const merged = { ...base, ...inner };
				if (merged.id == null && base.id != null) merged.id = base.id;
				if (merged.foodId == null && base.foodId != null) merged.foodId = base.foodId;
				if (merged.food_id == null && base.food_id != null) merged.food_id = base.food_id;
				// inner 常见为 vo/dto 子集：子对象里 image、nutrientsJson、nutrition 等可能为 null/""/[]，
				// 会覆盖外层列表行上已有字段，导致列表整页无图、无营养简介（BUG-003）。
				this.restoreNonEmptyFoodMediaFields(merged, base);
				return merged;
			}
			return base;
		},
		/**
		 * 列表行外层常有 id/name，图片与营养在 data/vo 内；仅在目标字段为空时用 fill 补齐（BUG-003）
		 */
		shallowFillEmptyFields(target, fill) {
			if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
			if (!fill || typeof fill !== 'object' || Array.isArray(fill)) return target;
			const out = { ...target };
			const keys = [
				'id', 'foodId', 'food_id', 'foodID', 'v2FoodId', 'v2_food_id', 'toolFoodId', 'tool_food_id',
				'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
				'foodImageUrl', 'food_image_url', 'photoUrl', 'photo_url',
				'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
				'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
				'nutrientVoList', 'nutrient_vo_list', 'nutritionOverview', 'nutrition_overview',
				'energy', 'protein', 'potassium', 'phosphorus', 'sodium', 'calcium', 'fat', 'carbohydrate', 'carbs',
				'suitabilityLevel', 'suitability_level'
			];
			for (let i = 0; i < keys.length; i++) {
				const k = keys[i];
				if (!(k in fill)) continue;
				const v = fill[k];
				if (v == null || v === '') continue;
				const cur = out[k];
				const curEmpty = cur == null || cur === '' || (Array.isArray(cur) && cur.length === 0);
				if (curEmpty) out[k] = v;
			}
			return out;
		},
		coalesceFoodImageField(item) {
			if (!item || typeof item !== 'object') return '';
			// 后端 ToolFoodServiceImpl 列表为 map.put("image", …)；兼容 imageUrl、蛇形命名、OSS 字段等
			if (Array.isArray(item.images) && item.images.length > 0) {
				const firstImg = item.images[0];
				if (typeof firstImg === 'string' && firstImg.trim()) return firstImg.trim();
				if (firstImg && typeof firstImg === 'object' && !Array.isArray(firstImg)) {
					const u = firstImg.url || firstImg.src || firstImg.path || firstImg.uri;
					if (u != null && String(u).trim()) return String(u).trim();
				}
			}
			const candidates = [
				item.image, item.imageUrl, item.image_url, item.foodImageUrl, item.food_image_url,
				item.picture, item.pictureUrl, item.picture_url,
				item.foodPicture, item.food_picture,
				item.imagePath, item.image_path, item.fileUrl, item.file_url,
				item.img, item.imgUrl, item.img_url, item.imageSrc, item.image_src,
				item.foodImage, item.food_image, item.pic, item.picUrl, item.pic_url,
				item.coverImage, item.cover_image, item.coverUrl, item.cover_url, item.cover,
				item.photo, item.photoUrl, item.photo_url, item.picture, item.pictureUrl,
				item.thumbnail, item.thumb, item.icon, item.headImg, item.head_img,
				item.foodPic, item.food_pic, item.food_pic_url,
				item.mainImage, item.main_image,
				item.foodImg, item.food_img, item.logo, item.avatar, item.banner,
				item.foodUrl, item.food_url, item.dishImage, item.dish_image,
				item.Image,
				item.coverImageUrl,
				item.ossUrl, item.oss_url, item.cdnUrl, item.cdn_url
			];
			for (let i = 0; i < candidates.length; i++) {
				const v = candidates[i];
				if (Array.isArray(v) && v.length > 0) {
					const first = v[0];
					if (first != null && typeof first === 'object' && !Array.isArray(first)) {
						const nested = first.url || first.src || first.path || first.uri;
						if (nested != null && String(nested).trim() !== '') return String(nested).trim();
					} else if (first != null && String(first).trim() !== '' && String(first) !== 'null') {
						return String(first).trim();
					}
					continue;
				}
				if (v != null && typeof v === 'object' && !Array.isArray(v)) {
					const nested = v.url || v.src || v.path || v.uri;
					if (nested != null && String(nested).trim() !== '') return String(nested).trim();
					continue;
				}
				if (v != null && String(v).trim() !== '' && String(v) !== 'null' && String(v) !== 'undefined') {
					return String(v).trim();
				}
			}
			return '';
		},
		/** .food-image 的 :src：有效图址优先，否则本地占位（保证 src 非空，便于各端与 E2E） */
		foodImageSrc(item, index) {
			const resolved = this.getFoodImage(item, index);
			const placeholder = this.defaultPlaceholder || '/static/images/food-placeholder.svg';
			return (resolved && String(resolved).trim()) ? String(resolved).trim() : placeholder;
		},
		/**
		 * 模板 .food-image :src：必须走 getFoodImage/foodImageSrc（含 coalesce、base、imageErrorIds、占位图）。
		 * 禁止仅用 imageUrl/image 短路返回，否则会绕过加载失败后的占位与部分字段映射。
		 */
		displayFoodImage(item, index) {
			try {
				return this.foodImageSrc(item, index);
			} catch (e) {
				return this.defaultPlaceholder || '/static/images/food-placeholder.svg';
			}
		},
		/**
		 * 模板 .food-image :src：须与 getFoodImage 同源（BUG-003）。
		 * 旧版曾用 imageUrl||image 短路：以 / 开头的相对路径未拼 HTTP_REQUEST_URL，H5 会请求到页面域名导致整页无图；
		 * 且会绕过 imageErrorIds，加载失败后无法回退占位图。
		 */
		resolvedFoodImage(item, index) {
			return this.foodImageSrc(item, index);
		},
		/**
		 * 按食物分类返回 emoji 占位（test-0415 反馈4-2）
		 * 优先 categoryType（fruit/vegetable/...），回落到 category 中文文案匹配
		 */
		categoryPlaceholderEmoji(item) {
			const map = {
				grain: '🌾', vegetable: '🥬', fruit: '🍎',
				meat: '🍖', seafood: '🐟', dairy: '🥛',
				bean: '🫘', nut: '🥜',
			};
			const key = this.resolveCategoryKey(item);
			return map[key] || '🍽️';
		},
		categoryPlaceholderBg(item) {
			const map = {
				grain: '#FFF4D6', vegetable: '#E6F7E6', fruit: '#FFE6E6',
				meat: '#F4E1D2', seafood: '#DCEEF7', dairy: '#F1F1F8',
				bean: '#EFE5D5', nut: '#F4E5C8',
			};
			const key = this.resolveCategoryKey(item);
			return map[key] || '#F2F4F8';
		},
		resolveCategoryKey(item) {
			if (!item) return '';
			const t = item.categoryType || item.category_type;
			if (t && typeof t === 'string') return t.toLowerCase();
			const c = item.category;
			if (!c || typeof c !== 'string') return '';
			if (c.includes('谷') || c.includes('薯')) return 'grain';
			if (c.includes('蔬')) return 'vegetable';
			if (c.includes('果')) return 'fruit';
			if (c.includes('肉') || c.includes('蛋') || c.includes('禽')) return 'meat';
			if (c.includes('鱼') || c.includes('水产') || c.includes('虾') || c.includes('蟹')) return 'seafood';
			if (c.includes('奶') || c.includes('乳')) return 'dairy';
			if (c.includes('豆')) return 'bean';
			if (c.includes('坚果')) return 'nut';
			return '';
		},
		hasFoodImage(item, index) {
			const url = this.getFoodImage(item, index);
			return !!(url && String(url).trim());
		},
		/** 是否存在可请求的远程配图（不含占位图；失败写入 imageErrorIds 后为 false，便于改显 view 占位）（BUG-003） */
		hasRemoteFoodImageSrc(item, index) {
			if (!item) return false;
			const errKey = this.imageErrorKey(item, index);
			if (errKey && this.imageErrorIds[errKey]) return false;
			const raw = this.coalesceFoodImageField(item);
			const s = (raw != null && String(raw).trim()) ? String(raw).trim() : '';
			if (!s || s === 'null' || s === 'undefined') return false;
			return true;
		},
		/** 与 hasRemoteFoodImageSrc 成对使用：仅输出拼接 HTTP_REQUEST_URL 后的远程地址 */
		remoteFoodImageUrl(item, index) {
			return this.getFoodImage(item, index);
		},
		/** 须始终非空键，否则 @error 无法写入 imageErrorIds，失败图无法回退占位（BUG-003） */
		imageErrorKey(item, index) {
			const i = typeof index === 'number' ? index : 0;
			if (!item) {
				return 'row:empty@' + i;
			}
			const id = item.id != null ? item.id : (item.foodId != null ? item.foodId : item.food_id);
			if (id != null && String(id).trim() !== '') return 'id:' + String(id) + '@' + i;
			const n = item.name != null ? String(item.name).trim() : '';
			if (n) return 'name:' + n + '@' + i;
			return 'idx:' + i + ':' + (n || 'row');
		},
		getFoodImage(item, index) {
			if (!item) return '';
			const errKey = this.imageErrorKey(item, index);
			if (errKey && this.imageErrorIds[errKey]) return '';
			const raw = this.coalesceFoodImageField(item);
			const s = (raw != null && String(raw).trim()) ? String(raw).trim() : '';
			if (!s || s === 'null' || s === 'undefined') return '';
			if (s.startsWith('data:')) return s;
			const base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim()) ? String(HTTP_REQUEST_URL).replace(/\/$/, '') : '';
			const url = (s.startsWith('//') || s.startsWith('http')) ? s : (s.startsWith('/') ? base + s : (base ? base + '/' + s : s));
			const finalUrl = (url && String(url).trim()) ? url : '';
			if (!finalUrl) return '';
			if (finalUrl.startsWith('data:') || finalUrl.startsWith('//') || finalUrl.startsWith('http') || finalUrl.startsWith('/')) return finalUrl;
			// 无前导斜杠的相对路径：有 base 时已拼成绝对地址；无 base 时仍返回相对路径，由当前站点根路径解析（避免误当成无图）
			if (base) {
				return base + '/' + finalUrl.replace(/^\//, '');
			}
			return finalUrl;
		},
		nutritionListForItem(item) {
			// normalizeFoodItem 已将 nutrientsJson + 扁平营养字段合并为 nutrition；须优先使用，避免 nutrientsJson
			// 先被解析出少量无效行而提前 return，导致忽略已合并好的 item.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 nOverview = item && takeNonEmpty(arrOrParse(item.nutritionOverview != null ? item.nutritionOverview : item.nutrition_overview));
			if (nOverview) return nOverview;
			const list = this.getNutritionList(item);
			const out = Array.isArray(list) ? list : [];
			if (out.length > 0) return out;
			return [
				{ label: '能量', value: '—', colorClass: 'green' },
				{ label: '蛋白质', value: '—', colorClass: 'green' },
				{ label: '钾', value: '—', colorClass: 'green' }
			];
		},
		/**
		 * 模板 .nutrition-item v-for：统一用 nutritionListForItem（含 nutrition/nutrients/nutrientsJson/扁平字段），
		 * 再规范 label/value（name/title/nutrientName 等与列表接口对象形态）。
		 */
		/** 列表营养值：兼容 BigDecimal 序列化对象、{ value, unit } 等 */
		nutritionScalarToDisplayString(rawValue) {
			if (rawValue == null || rawValue === '') return '—';
			if (typeof rawValue === 'object' && !Array.isArray(rawValue) && rawValue !== null) {
				if ('valueOf' in rawValue && typeof rawValue.valueOf === 'function') {
					try {
						const v = rawValue.valueOf();
						if (v != null && v !== rawValue && (typeof v === 'string' || typeof v === 'number')) {
							return String(v);
						}
					} catch (e) { /* ignore */ }
				}
				const inner = rawValue.value != null ? rawValue.value
					: (rawValue.amount != null ? rawValue.amount : (rawValue.val != null ? rawValue.val : rawValue.content));
				if (inner != null && inner !== '') return this.nutritionScalarToDisplayString(inner);
				return '—';
			}
			const s = String(rawValue);
			return s.trim() === '' || s === 'null' || s === 'undefined' ? '—' : s;
		},
		displayNutritionList(item) {
			try {
				const list = this.nutritionListForItem(item);
				const rows = Array.isArray(list) && list.length > 0 ? list : [
					{ label: '能量', value: '—', colorClass: 'green' },
					{ label: '蛋白质', value: '—', colorClass: 'green' },
					{ label: '钾', value: '—', colorClass: 'green' }
				];
				return rows.map((n) => {
					if (!n || typeof n !== 'object') return { label: '—', value: '—', colorClass: 'green' };
					const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val));
					const unit = n.unit != null ? String(n.unit) : '';
					const valueStr = this.nutritionScalarToDisplayString(rawValue);
					return {
						label: n.label || n.name || n.title || n.key || n.nutrientName || n.nutrient_name || n.labelName || n.text || '—',
						value: valueStr !== '—' && unit && !valueStr.endsWith(unit) ? (valueStr + unit) : valueStr,
						colorClass: n.colorClass || n.color || 'green'
					};
				});
			} catch (e) {
				return [
					{ label: '能量', value: '—', colorClass: 'green' },
					{ label: '蛋白质', value: '—', colorClass: 'green' },
					{ label: '钾', value: '—', colorClass: 'green' }
				];
			}
		},
		/**
		 * 取第一条「非空」营养数组。不能用 nutrients||nutrition：空数组 [] 在 JS 中为 truthy，
		 * 会短路掉后面已有数据的 nutrition（网关/合并后常见 nutrients=[]、nutrition 有值）（BUG-003）。
		 */
		firstNonEmptyNutritionArray(item) {
			if (!item || typeof item !== 'object') return null;
			const asNonEmptyArray = (v) => {
				if (Array.isArray(v) && v.length > 0) return v;
				if (typeof v === 'string' && v.trim()) {
					try {
						const j = JSON.parse(v);
						if (Array.isArray(j) && j.length > 0) return j;
					} catch (e) { /* ignore */ }
				}
				return null;
			};
			const keys = [
				'nutrition', 'nutrients', 'nutritions',
				'nutrientList', 'nutritionList', 'nutrition_list',
				'nutrientVoList', 'nutrient_vo_list', 'nutrientItems', 'nutritionItems'
			];
			for (let i = 0; i < keys.length; i++) {
				const hit = asNonEmptyArray(item[keys[i]]);
				if (hit) return hit;
			}
			return null;
		},
		/** 模板 .nutrition-item v-for：非空数组优先，否则 displayNutritionList（nutrientsJson/扁平字段/默认行） */
		resolvedNutritionList(item) {
			try {
				const mapNutRow = (n) => {
					try {
						if (!n || typeof n !== 'object') return { label: '—', value: '—', colorClass: 'green' };
						const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val));
						const unit = n.unit != null ? String(n.unit) : '';
						const valueStr = this.nutritionScalarToDisplayString(rawValue);
						return {
							label: n.label || n.name || n.title || n.key || n.nutrientName || n.nutrient_name || n.labelName || n.text || '—',
							value: valueStr !== '—' && unit && !valueStr.endsWith(unit) ? (valueStr + unit) : valueStr,
							colorClass: n.colorClass || n.color || 'green'
						};
					} catch (e) {
						return { label: '—', value: '—', colorClass: 'green' };
					}
				};
				// 勿用 nutrients||nutrition：空数组 [] 为 truthy，会挡住后面有数据的 nutrition（BUG-003）
				const arr = this.firstNonEmptyNutritionArray(item);
				if (arr) {
					const mapped = arr.map(mapNutRow).filter((r) => r != null);
					if (mapped.length > 0) return mapped;
				}
				return this.displayNutritionList(item);
			} catch (e) {
				return [
					{ label: '能量', value: '—', colorClass: 'green' },
					{ label: '蛋白质', value: '—', colorClass: 'green' },
					{ label: '钾', value: '—', colorClass: 'green' }
				];
			}
		},
		getNutritionList(item) {
			if (!item) return [];
			// 空数组在 JS 中为 truthy，不能用 a || b；否则会误用 [] 而跳过 nutrientsJson / 扁平字段
			const pickNutritionArray = () => {
				const keys = [
					'nutrition', 'nutrients', 'nutritions',
					'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
					'nutritionList', 'nutrientList', 'nutrientItems',
					'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
					'nutritionInfo', 'nutrition_info_json',
					'nutritionBrief', 'nutrition_brief', 'nutritionOverview', 'nutrition_overview',
					'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
				];
				for (let i = 0; i < keys.length; i++) {
					const v = item[keys[i]];
					if (Array.isArray(v) && v.length > 0) return v;
				}
				return null;
			};
			let arr = pickNutritionArray();
			if (arr == null) {
				const keys = [
					'nutrition', 'nutrients', 'nutritions',
					'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
					'nutritionList', 'nutrientList', 'nutrientItems',
					'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
					'nutritionInfo', 'nutrition_info_json',
					'nutritionBrief', 'nutrition_brief', 'nutritionOverview', 'nutrition_overview',
					'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
				];
				for (let i = 0; i < keys.length; i++) {
					const v = item[keys[i]];
					if (v == null || Array.isArray(v)) continue;
					if (typeof v === 'string' && !v.trim()) continue;
					arr = v;
					break;
				}
			}
			if (typeof arr === 'string' && arr.trim()) {
				try {
					const j = JSON.parse(arr);
					if (Array.isArray(j)) arr = j;
					else if (j && typeof j === 'object') arr = j;
					else arr = null;
				} catch (e) {
					arr = null;
				}
			}
			if (arr && typeof arr === 'object' && !Array.isArray(arr)) {
				arr = Object.keys(arr).slice(0, 12).map((k) => ({ label: k, value: arr[k], colorClass: 'green' }));
			}
			if (Array.isArray(arr) && arr.length > 0) {
				return arr.map((n) => {
					if (!n || typeof n !== 'object') {
						return { label: '—', value: '—', colorClass: 'green' };
					}
					const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val));
					const unit = n.unit != null ? String(n.unit) : '';
					const baseStr = this.nutritionScalarToDisplayString(rawValue);
					const value = baseStr !== '—' ? (unit && !baseStr.endsWith(unit) ? baseStr + unit : baseStr) : '—';
					return {
						label: n.label || n.name || n.title || n.key || n.nutrientName || '—',
						value,
						colorClass: n.colorClass || n.color || 'green'
					};
				});
			}
			const parsed = this.parseNutrientsFromItem(item);
			if (parsed && parsed.length > 0) return parsed;
			const list = [];
			const push = (label, val, unit) => {
				const num = val != null && val !== '' && typeof val === 'object' && 'valueOf' in val ? val.valueOf() : val;
				const value = (num != null && num !== '') ? String(num) + (unit || '') : '—';
				list.push({ label, value, colorClass: 'green' });
			};
			const e = item.energy != null ? item.energy : (item.energyKcal != null ? item.energyKcal : item.energy_kcal);
			const p = item.protein != null ? item.protein : (item.proteinG != null ? item.proteinG : item.protein_g);
			const k = item.potassium != null ? item.potassium : (item.potassiumMg != null ? item.potassiumMg : item.potassium_mg);
			const ph = item.phosphorus != null ? item.phosphorus : (item.phosphorusMg != null ? item.phosphorusMg : item.phosphorus_mg);
			const na = item.sodium != null ? item.sodium : (item.sodiumMg != null ? item.sodiumMg : item.sodium_mg);
			const ca = item.calcium != null ? item.calcium : (item.calciumMg != null ? item.calciumMg : item.calcium_mg);
			push('能量', e, 'kcal');
			push('蛋白质', p, 'g');
			push('钾', k, 'mg');
			push('磷', ph, 'mg');
			push('钠', na, 'mg');
			push('钙', ca, 'mg');
			return list.length > 0 ? list : [{ label: '能量', value: '—', colorClass: 'green' }, { label: '蛋白质', value: '—', colorClass: 'green' }, { label: '钾', value: '—', colorClass: 'green' }];
		},
		onFoodImageError(item, index, ev) {
			try {
				const src = ev && ev.target && (ev.target.src || ev.target.currentSrc);
				if (src && String(src).indexOf('food-placeholder') !== -1) return;
			} catch (e) { /* ignore */ }
			const key = this.imageErrorKey(item, index);
			if (key && !this.imageErrorIds[key]) {
				this.imageErrorIds = { ...this.imageErrorIds, [key]: true };
			}
		},
		/** 从 nutrition / nutrients / nutritions / nutrientsJson 解析为列表项 */
		parseNutrientsFromItem(item) {
			if (!item || typeof item !== 'object') return null;
			const mapNut = (n) => {
				if (n == null) {
					return { label: '—', value: '—', colorClass: 'green' };
				}
				if (typeof n === 'string' || typeof n === 'number' || typeof n === 'boolean') {
					const s = String(n).trim();
					return { label: '—', value: s !== '' ? s : '—', colorClass: 'green' };
				}
				if (typeof n !== 'object' || Array.isArray(n)) {
					return { label: '—', value: '—', colorClass: 'green' };
				}
				const u = n.unit != null && String(n.unit).trim() !== '' ? String(n.unit).trim() : '';
				let val = '';
				if (n.value != null && n.value !== '') {
					val = this.nutritionScalarToDisplayString(n.value);
					if (u && val !== '—' && !val.endsWith(u)) val += u;
				} else if (n.amount != null && n.amount !== '') {
					val = this.nutritionScalarToDisplayString(n.amount);
					if (u && val !== '—' && !val.endsWith(u)) val += u;
				} else if (n.val != null && n.val !== '') {
					val = this.nutritionScalarToDisplayString(n.val);
					if (u && val !== '—' && !val.endsWith(u)) val += u;
				} else {
					val = '—';
				}
				return {
					label: n.label || n.name || n.labelName || n.nutrientName || n.nutrient_name || n.key || n.text || '—',
					value: val,
					colorClass: n.colorClass || 'green'
				};
			};
			const tryArr = (arr) => {
				if (!Array.isArray(arr) || arr.length === 0) return null;
				const rows = arr.map(mapNut).filter((row) => row != null);
				return rows.length > 0 ? rows : null;
			};
			const tryJsonString = (s) => {
				if (typeof s !== 'string' || !s.trim()) return null;
				try {
					const j = JSON.parse(s);
					if (Array.isArray(j) && j.length > 0) return j.map(mapNut);
					if (j && typeof j === 'object' && !Array.isArray(j)) {
						const keys = Object.keys(j);
						if (keys.length === 0) return null;
						return keys.slice(0, 12).map((k) => ({
							label: k,
							value: j[k] != null ? String(j[k]) : '—',
							colorClass: 'green'
						}));
					}
				} catch (e) { /* ignore */ }
				return null;
			};
			/** 后端 nutrientsJson 可能被反序列化为对象/数组，不能仅按字符串解析 */
			const nutrientsJsonValue = (it) => {
				if (!it || typeof it !== 'object') return null;
				const v = it.nutrientsJson != null ? it.nutrientsJson
					: (it.nutrients_json != null ? it.nutrients_json
						: (it.nutritionJson != null ? it.nutritionJson : it.nutrition_json));
				return v != null && v !== '' ? v : null;
			};
			const fromNutrientsJson = (nj) => {
				if (nj == null || nj === '') return null;
				if (Array.isArray(nj) && nj.length > 0) return nj.map(mapNut);
				if (typeof nj === 'object' && !Array.isArray(nj)) {
					const keys = Object.keys(nj);
					if (keys.length === 0) return null;
					return keys.slice(0, 12).map((k) => ({
						label: k,
						value: nj[k] != null ? String(nj[k]) : '—',
						colorClass: 'green'
					}));
				}
				if (typeof nj === 'string') {
					try {
						const j = JSON.parse(nj);
						if (Array.isArray(j) && j.length > 0) return j.map(mapNut);
						if (j && typeof j === 'object' && !Array.isArray(j)) {
							const keys = Object.keys(j);
							if (keys.length === 0) return null;
							return keys.slice(0, 12).map((k) => ({
								label: k,
								value: j[k] != null ? String(j[k]) : '—',
								colorClass: 'green'
							}));
						}
					} catch (e) { /* ignore */ }
				}
				return null;
			};
			let out = fromNutrientsJson(nutrientsJsonValue(item));
			// 空数组 [] 在 JS 中为 truthy，不能用 if (out)，否则会跳过 nutrition/nutrients 等后备字段
			if (out && out.length > 0) return out;
			out = tryArr(item.nutrition);
			if (out && out.length > 0) return out;
			out = tryArr(item.nutrients);
			if (out && out.length > 0) return out;
			out = tryArr(item.nutritions);
			if (out && out.length > 0) return out;
			out = tryJsonString(item.nutrition);
			if (out && out.length > 0) return out;
			out = tryJsonString(item.nutrients);
			if (out && out.length > 0) return out;
			out = fromNutrientsJson(item.nutritionOverview != null ? item.nutritionOverview : item.nutrition_overview);
			if (out && out.length > 0) return out;
			return null;
		},
		normalizeFoodItem(raw) {
			const categoryNameMap = {
				grain: '谷薯类', vegetable: '蔬菜类', fruit: '水果类',
				meat: '肉蛋类', seafood: '水产类', dairy: '奶类',
				bean: '豆类', nut: '坚果类', all: '全部'
			};
			let item = this.unwrapFoodListRow(raw);
			// 网关偶发把整行放在 data 的 JSON 字符串里；仅 object 的 data 会被 shallowFill，字符串需先解析（BUG-003）
			if (item && typeof item === 'object' && !Array.isArray(item) && typeof item.data === 'string') {
				const t = item.data.trim();
				if (t.startsWith('{') && t.length > 1) {
					try {
						const parsed = JSON.parse(item.data);
						if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
							const merged = { ...item, ...parsed };
							this.restoreNonEmptyFoodMediaFields(merged, item);
							item = merged;
						}
					} catch (e) { /* ignore */ }
				}
			}
			if (item && typeof item === 'object' && !Array.isArray(item)) {
				const nests = [item.data, item.vo, item.record, item.detail, item.v2Food, item.v2_food, item.food, item.toolFood, item.tool_food, item.attrs, item.attr, item.extra].filter(
					(x) => x && typeof x === 'object' && !Array.isArray(x)
				);
				for (let ni = 0; ni < nests.length; ni++) {
					item = this.shallowFillEmptyFields(item, nests[ni]);
				}
			}
			if (!item || typeof item !== 'object') {
				return {
					name: '',
					image: '',
					imageUrl: '',
					nutrition: [
						{ label: '能量', value: '—', colorClass: 'green' },
						{ label: '蛋白质', value: '—', colorClass: 'green' },
						{ label: '钾', value: '—', colorClass: 'green' }
					],
					category: '',
					safety: '—',
					safetyClass: 'safe'
				};
			}
			const safetyMap = {
				suitable: { safety: '放心吃', safetyClass: 'safe' },
				moderate: { safety: '限量吃', safetyClass: 'limited' },
				restricted: { safety: '谨慎吃', safetyClass: 'careful' },
				forbidden: { safety: '谨慎吃', safetyClass: 'careful' }
			};
			const suitabilityLevel = item.suitabilityLevel != null && item.suitabilityLevel !== ''
				? item.suitabilityLevel
				: item.suitability_level;
			const safety = item.safety != null
				? { safety: item.safety, safetyClass: item.safetyClass || 'safe' }
				: (safetyMap[suitabilityLevel] || { safety: '—', safetyClass: 'safe' });

			const rawStr = this.coalesceFoodImageField(item);
			const validRaw = rawStr && rawStr !== 'null' && rawStr !== 'undefined';
			const base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim()) ? String(HTTP_REQUEST_URL).replace(/\/$/, '') : '';
			let image = '';
			if (validRaw) {
				if (rawStr.startsWith('data:')) image = rawStr;
				else if (rawStr.startsWith('//') || rawStr.startsWith('http')) image = rawStr;
				else if (rawStr.startsWith('/')) image = base ? base + rawStr : rawStr;
				else image = base ? base + '/' + rawStr : rawStr;
			}

			let nutrition = null;
			try {
				nutrition = this.parseNutrientsFromItem(item);
			} catch (e) {
				console.warn('[food-encyclopedia] parseNutrientsFromItem 失败，回退扁平营养字段', e);
				nutrition = null;
			}
			if (!nutrition || nutrition.length === 0) {
				nutrition = [];
				const push = (label, val, unit) => {
					const base = this.nutritionScalarToDisplayString(val);
					const value = base !== '—' ? base + (unit || '') : '—';
					nutrition.push({ label, value, colorClass: 'green' });
				};
				const e = item.energy != null ? item.energy : (item.energyKcal != null ? item.energyKcal : item.energy_kcal);
				const p = item.protein != null ? item.protein : (item.proteinG != null ? item.proteinG : item.protein_g);
				const k = item.potassium != null ? item.potassium : (item.potassiumMg != null ? item.potassiumMg : item.potassium_mg);
				const ph = item.phosphorus != null ? item.phosphorus : (item.phosphorusMg != null ? item.phosphorusMg : item.phosphorus_mg);
				const na = item.sodium != null ? item.sodium : (item.sodiumMg != null ? item.sodiumMg : item.sodium_mg);
				const ca = item.calcium != null ? item.calcium : (item.calciumMg != null ? item.calciumMg : item.calcium_mg);
				push('能量', e, 'kcal');
				push('蛋白质', p, 'g');
				push('钾', k, 'mg');
				push('磷', ph, 'mg');
				push('钠', na, 'mg');
				push('钙', ca, 'mg');
			}
			if (!nutrition.length) {
				nutrition = [
					{ label: '能量', value: '—', colorClass: 'green' },
					{ label: '蛋白质', value: '—', colorClass: 'green' },
					{ label: '钾', value: '—', colorClass: 'green' }
				];
			}

			// 与 goToFoodDetail 一致：在 id / foodId / food_id 等字段中找第一个纯数字 ID，避免 id 被误填为名称时整条链路传错
			const pickFirstNumericFoodIdStr = (it) => {
				const cands = [
					it.id,
					it.foodId,
					it.food_id,
					it.v2FoodId,
					it.v2_food_id,
					it.foodID,
					it.toolFoodId,
					it.tool_food_id
				];
				for (let i = 0; i < cands.length; i++) {
					const n = normalizeFoodDetailIdString(cands[i]);
					if (n !== '') return n;
				}
				return '';
			};
			const resolvedIdStr = pickFirstNumericFoodIdStr(item);
			const rawCategory = item.category || item.categoryName || item.category_name || '';
			const category = categoryNameMap[rawCategory] || rawCategory;
			const resolvedName = (item.name != null && String(item.name).trim() !== '')
				? String(item.name).trim()
				: String(item.foodName || item.title || item.food_name || '').trim();
			// 仅当解析出合法数字 ID 时覆盖 id/foodId（整数字符串，避免 parseInt/Number 丢精度）；勿写 null
			const idFields =
				resolvedIdStr !== ''
					? { id: resolvedIdStr, foodId: resolvedIdStr }
					: {};
			return {
				...item,
				...idFields,
				name: resolvedName || item.name || '—',
				image: image || '',
				imageUrl: image || '',
				img: image || '',
				category,
				safety: safety.safety,
				safetyClass: safety.safetyClass,
				nutrition: Array.isArray(nutrition) ? nutrition : [],
				nutrients: Array.isArray(nutrition) ? nutrition : [],
				nutritions: Array.isArray(nutrition) ? nutrition : [],
				energy: item.energy,
				protein: item.protein,
				potassium: item.potassium,
				phosphorus: item.phosphorus,
				sodium: item.sodium,
				calcium: item.calcium
			};
		},
		async selectCategory(category) {
			this.currentCategory = category;
			// 切换分类时清空搜索文本，避免搜索状态与分类状态冲突
			this.searchText = '';
			if (this.searchTimer) {
				clearTimeout(this.searchTimer);
			}
			await this.loadFoodList();
		},
		handleSearch() {
			// 防抖：300ms 内不重复触发搜索请求
			if (this.searchTimer) {
				clearTimeout(this.searchTimer);
			}
			this.searchTimer = setTimeout(async () => {
				if (this.searchText.trim()) {
					try {
						const { searchFood } = await import('@/api/tool.js');
						const result = await searchFood({
							keyword: this.searchText.trim(),
							page: 1,
							limit: 100
						});
						const code = result && (result.code != null ? Number(result.code) : 200);
						if (Number.isFinite(code) && code !== 200) {
							const errMsg = (result && (result.message || result.msg)) || '搜索失败';
							throw new Error(errMsg);
						}
						let rawList = this.getRawFoodList(this.unwrapFoodListApiResult(result));
						if (!Array.isArray(rawList)) rawList = [];
						this.imageErrorIds = {};
						this.foodList = (rawList || []).map((row) => {
							let r = row;
							if (typeof r === 'string' && r.trim()) {
								try {
									r = JSON.parse(r);
								} catch (e) { /* keep string */ }
							}
							try {
								return this.normalizeFoodItem(r);
							} catch (e) {
								console.warn('[food-encyclopedia] normalizeFoodItem 搜索行失败，已跳过', e);
								return null;
							}
						}).filter(i => i != null);
						this.$nextTick(() => this.updateFoodScrollHeight());
					} catch (error) {
						console.error('搜索失败:', error);
					}
				} else {
					await this.loadFoodList();
				}
			}, 300);
		},
		/**
		 * 点击食物卡片的入口方法：通过 index 从 filteredFoodList 实时获取 item。
		 * 小程序编译时 @click="fn(item)" 会通过 dataset 传递复杂对象，scroll-view 内
		 * DOM 回收或列表异步刷新后 dataset 可能丢失导致 item 为 undefined。
		 * 改为传递基本类型 index，在方法内从响应式数据实时取值，彻底解决此问题。
		 */
		handleFoodItemClick(index) {
			const item = this.filteredFoodList[index]
			if (!item || typeof item !== 'object') {
				console.warn('[food-encyclopedia] handleFoodItemClick: index', index, '对应 item 为空，filteredFoodList.length =', this.filteredFoodList.length)
				return
			}
			this.goToFoodDetail(item)
		},
		goToFoodDetail(item) {
			// 防御性校验（兜底，正常路径由 handleFoodItemClick 保证 item 有效）
			if (!item || typeof item !== 'object') {
				console.warn('[food-encyclopedia] goToFoodDetail: item 为空，跳过跳转', item)
				return
			}
			// 后端详情接口仅接受 Long 类型 id；与 getFoodDetail 共用规范化（支持 123.0 等，禁止名称进路径）
			const pickNumericIdStr = () => {
				const cands = [
					item.id,
					item.foodId,
					item.food_id,
					item.foodID,
					item.v2FoodId,
					item.v2_food_id,
					item.toolFoodId,
					item.tool_food_id
				]
				for (let i = 0; i < cands.length; i++) {
					const n = normalizeFoodDetailIdString(cands[i])
					if (n !== '') return n
				}
				return ''
			}
			const idStr = pickNumericIdStr()
			const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
			// 使用规范化后的整数字符串作为 query，避免 parseInt 大 Long 精度丢失与非十进制歧义
			const url = idStr !== ''
				? `/pages/tool/food-detail?id=${encodeURIComponent(idStr)}${namePart}`
				: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
			console.log('[food-encyclopedia] goToFoodDetail 跳转参数:', { idStr, name: item.name, url })
			uni.navigateTo({ url })
		}
	}
}
</script>

<style lang="scss" scoped>
.food-page {
	height: 100vh;
	display: flex;
	flex-direction: column;
	background-color: #f4f5f7;
}

/* 搜索框 */
.search-container {
	background: #ffffff;
	padding: 32rpx;
	border-bottom: 1rpx solid #d0dbea;
}

.search-box {
	display: flex;
	align-items: center;
	height: 88rpx;
	background: #ffffff;
	border: 1rpx solid #d0dbea;
	border-radius: 50rpx;
	padding: 0 32rpx;
	
	.search-icon {
		font-size: 32rpx;
		margin-right: 16rpx;
	}
	
	.search-input {
		flex: 1;
		font-size: 32rpx;
		color: #3e5481;
	}
}

/* 分类标签 */
.category-scroll {
	background: #ffffff;
	border-bottom: 1rpx solid #d0dbea;
	white-space: nowrap;
}

.category-list {
	display: flex;
	padding: 32rpx;
	gap: 16rpx;
	white-space: nowrap;
}

.category-item {
	display: flex;
	align-items: center;
	gap: 12rpx;
	height: 67rpx;
	padding: 0 32rpx;
	border-radius: 50rpx;
	border: 1rpx solid #d0dbea;
	background: #ffffff;
	white-space: nowrap;
	
	.category-icon {
		font-size: 28rpx;
	}
	
	text {
		font-size: 28rpx;
		color: #9fa5c0;
	}
	
	&.active {
		background: #ff6b35;
		border-color: #ff6b35;
		
		text {
			color: #ffffff;
		}
	}
}

/* 食物列表 */
.food-scroll {
	flex: 1;
	min-height: 0;
	overflow: hidden;
}

.food-list-container {
	padding: 32rpx;
}

.food-count {
	font-size: 28rpx;
	color: #9fa5c0;
	margin-bottom: 32rpx;
}

.food-list {
	display: flex;
	flex-direction: column;
	gap: 24rpx;
}

.food-item {
	background: #ffffff;
	border: 1rpx solid #d0dbea;
	border-radius: 32rpx;
	padding: 24rpx;
	display: flex;
	gap: 24rpx;
}

.food-image-wrapper {
	position: relative;
	width: 192rpx;
	height: 192rpx;
	border-radius: 24rpx;
	overflow: hidden;
	background: #f4f5f7;
	flex-shrink: 0;
}

.food-image {
	width: 100%;
	height: 100%;
	display: block;
	/* H5 使用原生 img 时与 image mode=aspectFill 一致 */
	object-fit: cover;
}

/* 无图或加载失败时的占位（按分类着色，emoji 居中） */
.food-image-placeholder {
	background: #f2f4f8;
	display: flex;
	align-items: center;
	justify-content: center;
}
.food-image-placeholder .category-emoji {
	font-size: 64rpx;
	line-height: 1;
}

.warning-badge {
	position: absolute;
	top: 8rpx;
	left: 8rpx;
	background: #ff6464;
	border-radius: 12rpx;
	padding: 4rpx 12rpx;
	font-size: 24rpx;
}

.food-info {
	flex: 1;
	min-width: 0;
	display: flex;
	flex-direction: column;
	gap: 16rpx;
}

.food-header {
	display: flex;
	justify-content: space-between;
	align-items: flex-start;
	gap: 16rpx;
}

.food-name-wrapper {
	flex: 1;
	display: flex;
	align-items: center;
	gap: 16rpx;
	flex-wrap: wrap;
}

.food-name {
	font-size: 28rpx;
	color: #2e3e5c;
	font-weight: 500;
}

.safety-tag {
	padding: 4rpx 16rpx;
	border-radius: 50rpx;
	font-size: 24rpx;
	
	&.safe {
		background: #e3fff1;
		color: #1fcc79;
	}
	
	&.careful {
		background: #ffe8e8;
		color: #ff6464;
	}
	
	&.limited {
		background: #fff8e1;
		color: #ff9800;
	}
}

.category-badge {
	background: #fff5f0;
	border: 1rpx solid #ff6b35;
	border-radius: 12rpx;
	padding: 4rpx 16rpx;
	
	text {
		font-size: 24rpx;
		color: #ff6b35;
		font-weight: 500;
	}
}

.nutrition-list {
	display: flex;
	flex-direction: column;
	gap: 8rpx;
}

.nutrition-item {
	display: flex;
	justify-content: space-between;
	align-items: center;
	
	.nutrition-label {
		font-size: 24rpx;
		color: #9fa5c0;
	}
	
	.nutrition-value {
		font-size: 24rpx;
		
		&.green {
			color: #1fcc79;
		}
		
		&.red {
			color: #ff6464;
		}
		
		&.orange {
			color: #ff9800;
		}
	}
}
</style>
