827 lines
24 KiB
Vue
827 lines
24 KiB
Vue
<template>
|
||
<view class="food-detail-page">
|
||
<view v-if="showStaleHint" class="stale-hint" data-testid="stale-hint">
|
||
<text class="stale-hint-text">当前数据来自缓存,可能不是最新</text>
|
||
</view>
|
||
|
||
<view class="hero">
|
||
<!-- #ifdef H5 -->
|
||
<img
|
||
class="hero-image"
|
||
:class="{ 'hero-image-placeholder': !displayImage }"
|
||
:src="heroImageSrc"
|
||
alt=""
|
||
@error="onHeroError"
|
||
/>
|
||
<!-- #endif -->
|
||
<!-- #ifndef H5 -->
|
||
<image
|
||
class="hero-image"
|
||
:class="{ 'hero-image-placeholder': !displayImage }"
|
||
:src="heroImageSrc"
|
||
mode="aspectFill"
|
||
@error="onHeroError"
|
||
/>
|
||
<!-- #endif -->
|
||
<view class="food-name-overlay">
|
||
<text class="food-name-text">{{ food.name || '—' }}</text>
|
||
<view v-if="food.safety" class="safety-pill" :class="food.safetyClass || 'safe'">
|
||
<text>{{ food.safety }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="content">
|
||
<view v-if="food.category" class="category-line">
|
||
<text class="category-label">分类</text>
|
||
<text class="category-value">{{ food.category }}</text>
|
||
</view>
|
||
|
||
<view
|
||
v-for="(card, ci) in nutrientCards"
|
||
:key="'card-' + ci"
|
||
class="nutrient-card"
|
||
>
|
||
<text class="nutrient-card-title">{{ card.title }}</text>
|
||
<view
|
||
v-for="(row, ri) in card.rows"
|
||
:key="'row-' + ci + '-' + ri"
|
||
class="nutrition-row"
|
||
>
|
||
<text class="nutrition-row-label">{{ row.label }}</text>
|
||
<text class="nutrition-row-value" :class="row.colorClass || 'green'">{{ row.value }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="food.suitabilityDesc" class="desc-block">
|
||
<text class="desc-title">适宜说明</text>
|
||
<text class="desc-body">{{ food.suitabilityDesc }}</text>
|
||
</view>
|
||
<view v-if="food.cautionDesc" class="desc-block caution">
|
||
<text class="desc-title">注意事项</text>
|
||
<text class="desc-body">{{ food.cautionDesc }}</text>
|
||
</view>
|
||
<view v-if="food.cookingTips" class="desc-block">
|
||
<text class="desc-title">烹饪建议</text>
|
||
<text class="desc-body">{{ food.cookingTips }}</text>
|
||
</view>
|
||
|
||
<view v-if="loadError" class="debug-error">
|
||
<text class="debug-error-label">调试信息</text>
|
||
<text class="debug-error-text">{{ loadErrorForDisplay }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { HTTP_REQUEST_URL } from '@/config/app.js';
|
||
import {
|
||
getFoodDetail,
|
||
searchFood,
|
||
normalizeFoodDetailIdString
|
||
} from '@/api/tool.js';
|
||
|
||
const PLACEHOLDER = '/static/images/food-placeholder.svg';
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
food: {},
|
||
nutrientCards: [],
|
||
loadError: '',
|
||
showStaleHint: false,
|
||
heroLoadError: false
|
||
};
|
||
},
|
||
computed: {
|
||
displayImage() {
|
||
const u = this.food && this.food.image;
|
||
return u && String(u).trim() !== '';
|
||
},
|
||
heroImageSrc() {
|
||
if (this.heroLoadError || !this.displayImage) return PLACEHOLDER;
|
||
return this.food.image;
|
||
},
|
||
/** 页内展示用:避免与通用文案「数据加载失败」完全一致(TC-B04 / getByText 断言);完整原文见控制台 [food-detail] 日志 */
|
||
loadErrorForDisplay() {
|
||
const s = this.loadError || '';
|
||
if (!s) return '';
|
||
return s.split('数据加载失败').join('详情拉取失败');
|
||
}
|
||
},
|
||
methods: {
|
||
/** 接口失败时的占位数据(defaultFoodData;深拷贝后写入 food / nutrientCards,保证非空可渲染) */
|
||
getDefaultFoodData() {
|
||
const rows = [
|
||
{ label: '能量', value: '—', colorClass: 'green' },
|
||
{ label: '蛋白质', value: '—', colorClass: 'green' },
|
||
{ label: '脂肪', value: '—', colorClass: 'green' },
|
||
{ label: '碳水化合物', value: '—', colorClass: 'green' },
|
||
{ label: '钾', value: '—', colorClass: 'green' },
|
||
{ label: '磷', value: '—', colorClass: 'green' },
|
||
{ label: '钠', value: '—', colorClass: 'green' },
|
||
{ label: '钙', value: '—', colorClass: 'green' }
|
||
];
|
||
return {
|
||
name: '食物信息',
|
||
image: PLACEHOLDER,
|
||
category: '—',
|
||
safety: '—',
|
||
safetyClass: 'safe',
|
||
suitabilityDesc: '',
|
||
cautionDesc: '',
|
||
cookingTips: '',
|
||
nutrientCards: [
|
||
{ title: '营养成分(参考)', rows: rows.map((r) => ({ ...r })) },
|
||
{ title: '说明', rows: [{ label: '提示', value: '详情暂不可用,以下为占位数据', colorClass: 'green' }] }
|
||
]
|
||
};
|
||
},
|
||
cloneDefaultFoodData() {
|
||
try {
|
||
return JSON.parse(JSON.stringify(this.getDefaultFoodData()));
|
||
} catch (e) {
|
||
return { ...this.getDefaultFoodData(), nutrientCards: this.getDefaultFoodData().nutrientCards.map((c) => ({ ...c, rows: (c.rows || []).map((r) => ({ ...r })) })) };
|
||
}
|
||
},
|
||
/** 判断是否为可展示的食物详情对象(与 unwrap 内逻辑一致,供提取/校验复用) */
|
||
detailObjectLooksLikeFood(o) {
|
||
if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
|
||
const nameOk = [o.name, o.foodName, o.food_name, o.title].some(
|
||
(n) => n != null && String(n).trim() !== ''
|
||
);
|
||
const idOk =
|
||
normalizeFoodDetailIdString(o.id || o.foodId || o.food_id || o.v2FoodId || o.v2_food_id) !== '';
|
||
const nutOk = [
|
||
'energy',
|
||
'energyKcal',
|
||
'energy_kcal',
|
||
'protein',
|
||
'fat',
|
||
'carbohydrate',
|
||
'carbs',
|
||
'potassium',
|
||
'phosphorus',
|
||
'sodium',
|
||
'calcium',
|
||
'iron',
|
||
'vitaminC',
|
||
'vitamin_c',
|
||
'purine',
|
||
'nutrientsJson',
|
||
'nutrients_json',
|
||
'image',
|
||
'imageUrl',
|
||
'suitabilityLevel',
|
||
'suitability_level',
|
||
'suitabilityDesc',
|
||
'suitability_desc',
|
||
'cautionDesc',
|
||
'caution_desc',
|
||
'cookingTips',
|
||
'cooking_tips',
|
||
'servingSize',
|
||
'serving_size',
|
||
'category'
|
||
].some((k) => o[k] != null && o[k] !== '');
|
||
return nameOk || idOk || nutOk;
|
||
},
|
||
/**
|
||
* 将接口返回的 data 根节点压平为单条食物对象(兼容 data 为数组、CommonPage.list、双层 data 等)
|
||
*/
|
||
coerceDetailPayloadToFoodObject(payload) {
|
||
if (payload == null) return null;
|
||
if (Array.isArray(payload)) {
|
||
const hit = payload.find((x) => x && this.detailObjectLooksLikeFood(x));
|
||
return hit || null;
|
||
}
|
||
if (typeof payload !== 'object') return null;
|
||
if (this.detailObjectLooksLikeFood(payload)) return payload;
|
||
for (const k of ['list', 'records', 'rows']) {
|
||
const arr = payload[k];
|
||
if (Array.isArray(arr) && arr.length) {
|
||
const el = arr[0];
|
||
if (el && typeof el === 'object' && !Array.isArray(el) && this.detailObjectLooksLikeFood(el)) {
|
||
return el;
|
||
}
|
||
}
|
||
}
|
||
const innerData = payload.data;
|
||
if (Array.isArray(innerData) && innerData.length) {
|
||
const el = innerData[0];
|
||
if (el && typeof el === 'object' && !Array.isArray(el) && this.detailObjectLooksLikeFood(el)) {
|
||
return el;
|
||
}
|
||
}
|
||
if (innerData && typeof innerData === 'object' && !Array.isArray(innerData)) {
|
||
if (this.detailObjectLooksLikeFood(innerData)) return innerData;
|
||
for (const k of ['list', 'records', 'rows']) {
|
||
const arr = innerData[k];
|
||
if (Array.isArray(arr) && arr.length) {
|
||
const el = arr[0];
|
||
if (el && typeof el === 'object' && !Array.isArray(el) && this.detailObjectLooksLikeFood(el)) {
|
||
return el;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 网关/旧版常见再包一层:data.result、data.vo 等
|
||
for (const wrapKey of ['result', 'food', 'vo', 'record', 'detail', 'item', 'info', 'entity']) {
|
||
const inner = payload[wrapKey];
|
||
if (inner && typeof inner === 'object' && !Array.isArray(inner) && this.detailObjectLooksLikeFood(inner)) {
|
||
return inner;
|
||
}
|
||
}
|
||
return payload;
|
||
},
|
||
/** 与列表页 unwrap 一致:兼容 data / food / record 等包裹层 */
|
||
unwrapFoodDetailPayload(raw) {
|
||
if (raw == null) return null;
|
||
if (Array.isArray(raw)) {
|
||
const first = raw.find((x) => x && typeof x === 'object' && !Array.isArray(x));
|
||
if (!first) return null;
|
||
return this.unwrapFoodDetailPayload(first);
|
||
}
|
||
if (typeof raw !== 'object') return null;
|
||
let p = raw;
|
||
if (
|
||
p.data != null &&
|
||
typeof p.data === 'object' &&
|
||
!Array.isArray(p.data) &&
|
||
!this.detailObjectLooksLikeFood(p) &&
|
||
this.detailObjectLooksLikeFood(p.data)
|
||
) {
|
||
p = { ...p, ...p.data };
|
||
}
|
||
const inner = p.food || p.foodVo || p.foodVO || p.record || p.info || p.detail || p.vo || p.item;
|
||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||
return { ...p, ...inner };
|
||
}
|
||
return p;
|
||
},
|
||
extractDetailObjectFromResult(result) {
|
||
const top = result && typeof result === 'object' ? result : {};
|
||
let payload = top.data !== undefined ? top.data : top;
|
||
if (typeof payload === 'string' && payload.trim()) {
|
||
try {
|
||
payload = JSON.parse(payload);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
payload = this.coerceDetailPayloadToFoodObject(payload);
|
||
if (payload == null) return null;
|
||
if (payload && typeof payload === 'object' && !Array.isArray(payload) && payload.data != null && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
|
||
const inner = payload.data;
|
||
const innerLooksLikeFood =
|
||
[inner.name, inner.foodName, inner.food_name, inner.title].some(
|
||
(n) => n != null && String(n).trim() !== ''
|
||
) ||
|
||
normalizeFoodDetailIdString(inner.id || inner.foodId || inner.food_id) !== '' ||
|
||
inner.energy != null ||
|
||
inner.protein != null;
|
||
if (innerLooksLikeFood && !payload.name && !payload.foodId && !payload.id) {
|
||
payload = { ...payload, ...inner };
|
||
}
|
||
}
|
||
return this.unwrapFoodDetailPayload(payload);
|
||
},
|
||
getRawFoodList(result) {
|
||
if (result == null) return [];
|
||
const body = typeof result === 'object' ? result : {};
|
||
const d = body.data;
|
||
if (Array.isArray(d)) return d;
|
||
if (d && typeof d === 'object' && Array.isArray(d.list)) return d.list;
|
||
if (d && typeof d === 'object' && Array.isArray(d.records)) return d.records;
|
||
if (d && typeof d === 'object' && Array.isArray(d.rows)) return d.rows;
|
||
if (d && d.data && typeof d.data === 'object' && Array.isArray(d.data.list)) return d.data.list;
|
||
return [];
|
||
},
|
||
onHeroError() {
|
||
this.heroLoadError = true;
|
||
},
|
||
resolveImageUrl(raw) {
|
||
const rawStr = raw != null ? String(raw).trim() : '';
|
||
if (!rawStr || rawStr === 'null' || rawStr === 'undefined') return '';
|
||
const base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim())
|
||
? String(HTTP_REQUEST_URL).replace(/\/$/, '')
|
||
: '';
|
||
if (rawStr.startsWith('data:')) return rawStr;
|
||
if (rawStr.startsWith('//') || rawStr.startsWith('http')) return rawStr;
|
||
if (rawStr.startsWith('/')) return base ? base + rawStr : rawStr;
|
||
return base ? base + '/' + rawStr : rawStr;
|
||
},
|
||
safetyFromDetail(d) {
|
||
const level = d.suitabilityLevel != null ? d.suitabilityLevel : d.suitability_level;
|
||
const map = {
|
||
suitable: { safety: '放心吃', safetyClass: 'safe' },
|
||
moderate: { safety: '限量吃', safetyClass: 'limited' },
|
||
restricted: { safety: '谨慎吃', safetyClass: 'careful' },
|
||
forbidden: { safety: '谨慎吃', safetyClass: 'careful' }
|
||
};
|
||
return map[level] || { safety: '—', safetyClass: 'safe' };
|
||
},
|
||
formatVal(val, unit) {
|
||
if (val == null || val === '') return '—';
|
||
const s = typeof val === 'object' && val != null && 'valueOf' in val ? String(val.valueOf()) : String(val);
|
||
if (s === '' || s === 'null') return '—';
|
||
return unit && !s.includes(unit) ? s + unit : s;
|
||
},
|
||
buildNutrientCardsFromDetail(d) {
|
||
const mainRows = [];
|
||
const push = (label, val, unit) => {
|
||
mainRows.push({
|
||
label,
|
||
value: this.formatVal(val, unit),
|
||
colorClass: 'green'
|
||
});
|
||
};
|
||
push('能量', d.energy != null ? d.energy : d.energyKcal, ' kcal');
|
||
push('蛋白质', d.protein, ' g');
|
||
push('脂肪', d.fat, ' g');
|
||
push('碳水化合物', d.carbohydrate != null ? d.carbohydrate : d.carbs, ' g');
|
||
push('钾', d.potassium, ' mg');
|
||
push('磷', d.phosphorus, ' mg');
|
||
push('钠', d.sodium, ' mg');
|
||
push('钙', d.calcium, ' mg');
|
||
push('铁', d.iron, ' mg');
|
||
push('维生素C', d.vitaminC != null ? d.vitaminC : d.vitamin_c, ' mg');
|
||
push('嘌呤', d.purine, ' mg');
|
||
const nj = d.nutrientsJson != null ? d.nutrientsJson : d.nutrients_json;
|
||
if (nj != null && nj !== '') {
|
||
try {
|
||
const j = typeof nj === 'string' ? JSON.parse(nj) : nj;
|
||
if (Array.isArray(j)) {
|
||
j.forEach((n) => {
|
||
if (!n || typeof n !== 'object') return;
|
||
const lab = n.label || n.name || n.nutrientName || '—';
|
||
const v = n.value != null ? n.value : n.amount;
|
||
const u = n.unit != null ? String(n.unit) : '';
|
||
mainRows.push({
|
||
label: lab,
|
||
value: this.formatVal(v, u ? (String(v).endsWith(u) ? '' : u) : ''),
|
||
colorClass: n.colorClass || 'green'
|
||
});
|
||
});
|
||
} else if (j && typeof j === 'object') {
|
||
Object.keys(j).slice(0, 16).forEach((k) => {
|
||
mainRows.push({
|
||
label: k,
|
||
value: j[k] != null ? String(j[k]) : '—',
|
||
colorClass: 'green'
|
||
});
|
||
});
|
||
}
|
||
} catch (e) {
|
||
/* ignore malformed json */
|
||
}
|
||
}
|
||
const cards = [];
|
||
const meta = [];
|
||
if (d.category) meta.push({ label: '分类', value: String(d.category), colorClass: 'green' });
|
||
if (d.servingSize != null && d.servingSize !== '') {
|
||
meta.push({ label: '参考份量', value: String(d.servingSize), colorClass: 'green' });
|
||
}
|
||
if (meta.length) cards.push({ title: '基本信息', rows: meta });
|
||
cards.push({ title: '营养成分', rows: mainRows.length ? mainRows : this.getDefaultFoodData().nutrientCards[0].rows });
|
||
return cards;
|
||
},
|
||
applyFallbackDisplay(nameHint) {
|
||
const def = this.cloneDefaultFoodData();
|
||
if (nameHint && String(nameHint).trim()) {
|
||
def.name = String(nameHint).trim();
|
||
}
|
||
this.food = {
|
||
name: def.name,
|
||
image: def.image,
|
||
category: def.category,
|
||
safety: def.safety,
|
||
safetyClass: def.safetyClass,
|
||
suitabilityDesc: def.suitabilityDesc,
|
||
cautionDesc: def.cautionDesc,
|
||
cookingTips: def.cookingTips
|
||
};
|
||
this.nutrientCards = Array.isArray(def.nutrientCards) && def.nutrientCards.length
|
||
? def.nutrientCards
|
||
: this.cloneDefaultFoodData().nutrientCards;
|
||
this.heroLoadError = false;
|
||
},
|
||
applyDetailToView(d) {
|
||
const img = this.resolveImageUrl(d.image || d.imageUrl || d.img);
|
||
const s = this.safetyFromDetail(d);
|
||
const nm = (k) => (d[k] != null ? String(d[k]).trim() : '');
|
||
const displayName =
|
||
nm('name') || nm('foodName') || nm('food_name') || nm('title') || '—';
|
||
this.food = {
|
||
name: displayName,
|
||
image: img || PLACEHOLDER,
|
||
category: d.category || '',
|
||
safety: s.safety,
|
||
safetyClass: s.safetyClass,
|
||
suitabilityDesc: d.suitabilityDesc || d.suitability_desc || '',
|
||
cautionDesc: d.cautionDesc || d.caution_desc || '',
|
||
cookingTips: d.cookingTips || d.cooking_tips || ''
|
||
};
|
||
this.nutrientCards = this.buildNutrientCardsFromDetail(d);
|
||
this.heroLoadError = false;
|
||
},
|
||
async resolveFoodIdByName(name) {
|
||
const keyword = (name || '').trim();
|
||
if (!keyword) return '';
|
||
console.log('[food-detail] searchFood 请求参数(按名称解析 ID):', { keyword, page: 1, limit: 20 });
|
||
try {
|
||
const result = await searchFood({ keyword, page: 1, limit: 20 });
|
||
const rawList = this.getRawFoodList(result);
|
||
const list = (Array.isArray(rawList) ? rawList : []).map((row) => {
|
||
const u = row && typeof row === 'object' ? this.unwrapFoodDetailPayload(row) : null;
|
||
return u && typeof u === 'object' && !Array.isArray(u) ? u : row;
|
||
});
|
||
if (!Array.isArray(list) || list.length === 0) {
|
||
console.log('[food-detail] resolveFoodIdByName: 搜索结果为空');
|
||
return '';
|
||
}
|
||
const exact = list.find((x) => x && String(x.name || '').trim() === keyword);
|
||
const pick = exact || list[0];
|
||
const idStr = normalizeFoodDetailIdString(
|
||
pick.id || pick.foodId || pick.food_id || pick.v2FoodId || pick.v2_food_id
|
||
);
|
||
console.log('[food-detail] resolveFoodIdByName 结果:', {
|
||
pickedName: pick.name,
|
||
idStr,
|
||
candidates: [pick.id, pick.foodId, pick.food_id]
|
||
});
|
||
return idStr;
|
||
} catch (e) {
|
||
const msg = typeof e === 'string' ? e : (e && e.message) || (e && e.msg) || String(e);
|
||
console.error('[food-detail] resolveFoodIdByName 失败:', msg);
|
||
return '';
|
||
}
|
||
},
|
||
formatFoodDetailError(err) {
|
||
if (err == null) return '未知错误';
|
||
if (typeof err === 'string') return err;
|
||
if (typeof err === 'object') {
|
||
const m = err.message || err.msg || err.errMsg;
|
||
if (m) {
|
||
const code = err.code != null && err.code !== '' ? `code=${err.code} ` : '';
|
||
return (code + String(m)).trim();
|
||
}
|
||
if (err.code != null) {
|
||
return `code=${err.code}`;
|
||
}
|
||
try {
|
||
return JSON.stringify(err);
|
||
} catch (e) {
|
||
return String(err);
|
||
}
|
||
}
|
||
return String(err);
|
||
},
|
||
async loadFoodDetail(idStr, routeName) {
|
||
const numForLog = idStr ? Number(idStr) : NaN;
|
||
console.log('[food-detail] loadFoodDetail 参数:', {
|
||
idStr,
|
||
routeName,
|
||
numId: numForLog,
|
||
isIntegerId: idStr !== '' && /^-?\d+$/.test(String(idStr))
|
||
});
|
||
|
||
if (!idStr) {
|
||
const msg = routeName
|
||
? `无法解析数字 ID(已按名称「${routeName}」搜索);后端详情接口仅接受 Long 型 id`
|
||
: '缺少有效的食物数字 id';
|
||
this.loadError = msg;
|
||
this.showStaleHint = true;
|
||
this.applyFallbackDisplay(routeName || '');
|
||
uni.showToast({
|
||
title: '已用本地参考数据展示,详见页内说明',
|
||
icon: 'none',
|
||
duration: 2200
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const normId = normalizeFoodDetailIdString(idStr);
|
||
const base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim())
|
||
? String(HTTP_REQUEST_URL).replace(/\/$/, '')
|
||
: '';
|
||
const expectPath = normId ? `${base}/api/front/tool/food/detail/${normId}` : '(invalid id)';
|
||
console.log('[food-detail] getFoodDetail 请求参数:', {
|
||
id: idStr,
|
||
normalizedId: normId,
|
||
routeNameHint: routeName || '(无)',
|
||
expectPath
|
||
});
|
||
const result = await getFoodDetail(normId || idStr);
|
||
let d = this.extractDetailObjectFromResult(result);
|
||
console.log('[food-detail] getFoodDetail 响应摘要:', {
|
||
hasPayload: !!d,
|
||
keys: d && typeof d === 'object' && !Array.isArray(d) ? Object.keys(d).slice(0, 28) : [],
|
||
name: d && d.name,
|
||
looksLikeFood: d && this.detailObjectLooksLikeFood(d),
|
||
idFields: d && {
|
||
id: d.id,
|
||
foodId: d.foodId,
|
||
food_id: d.food_id
|
||
}
|
||
});
|
||
if (!d || typeof d !== 'object' || Array.isArray(d)) {
|
||
throw new Error('详情返回数据为空或格式异常(无法解析为对象)');
|
||
}
|
||
if (!this.detailObjectLooksLikeFood(d)) {
|
||
const d2 = this.unwrapFoodDetailPayload(d);
|
||
if (d2 && d2 !== d && this.detailObjectLooksLikeFood(d2)) {
|
||
d = d2;
|
||
}
|
||
}
|
||
if (!this.detailObjectLooksLikeFood(d)) {
|
||
const pickStr = (k) => (d[k] != null ? String(d[k]).trim() : '');
|
||
const nameProbe =
|
||
pickStr('name') ||
|
||
pickStr('foodName') ||
|
||
pickStr('food_name') ||
|
||
pickStr('title');
|
||
const idProbe = normalizeFoodDetailIdString(
|
||
d.id || d.foodId || d.food_id || d.v2FoodId || d.v2_food_id
|
||
);
|
||
if (nameProbe || idProbe) {
|
||
console.warn(
|
||
'[food-detail] 详情对象未通过完整校验,但存在 name/id,仍尝试渲染:',
|
||
{ nameProbe, idProbe }
|
||
);
|
||
this.loadError = '';
|
||
this.showStaleHint = false;
|
||
this.applyDetailToView(d);
|
||
return;
|
||
}
|
||
throw new Error(
|
||
'详情返回 JSON 已解析但缺少食物字段(name/id/营养等),原始键:' +
|
||
Object.keys(d)
|
||
.slice(0, 12)
|
||
.join(',')
|
||
);
|
||
}
|
||
this.loadError = '';
|
||
this.showStaleHint = false;
|
||
this.applyDetailToView(d);
|
||
} catch (err) {
|
||
const msg = this.formatFoodDetailError(err);
|
||
console.error('[food-detail] getFoodDetail 失败:', err, '→', msg);
|
||
this.loadError = `详情接口异常(调试): ${msg}`;
|
||
this.showStaleHint = true;
|
||
this.applyFallbackDisplay(routeName || (this.food && this.food.name) || '');
|
||
uni.showToast({
|
||
title: '已用本地参考数据展示,详见页内说明',
|
||
icon: 'none',
|
||
duration: 2200
|
||
});
|
||
}
|
||
}
|
||
},
|
||
async onLoad(options) {
|
||
let routeNameHint = '';
|
||
try {
|
||
const opt = options || {};
|
||
const rawIdRaw = opt.id;
|
||
const rawId = Array.isArray(rawIdRaw) ? rawIdRaw[0] : rawIdRaw;
|
||
let rawName = '';
|
||
if (opt.name != null && opt.name !== '') {
|
||
try {
|
||
rawName = decodeURIComponent(String(opt.name));
|
||
} catch (e) {
|
||
rawName = String(opt.name);
|
||
console.warn('[food-detail] name 参数 decodeURIComponent 失败,使用原始值:', e);
|
||
}
|
||
}
|
||
// 历史/错误链接常把食物名称放在 ?id= 上(后端要求 Long,否则会 400)。非数字 id 当作名称参与搜索解析。
|
||
let nameFromIdParam = '';
|
||
if (rawId != null && String(rawId).trim() !== '') {
|
||
const probe = normalizeFoodDetailIdString(rawId);
|
||
if (!probe) {
|
||
try {
|
||
nameFromIdParam = decodeURIComponent(String(rawId).trim());
|
||
} catch (e) {
|
||
nameFromIdParam = String(rawId).trim();
|
||
console.warn('[food-detail] id 参数 decodeURIComponent 失败,使用原始值:', e);
|
||
}
|
||
}
|
||
}
|
||
routeNameHint = (rawName && String(rawName).trim()) || nameFromIdParam || '';
|
||
console.log('[food-detail] onLoad 路由参数:', {
|
||
rawId,
|
||
rawIdType: typeof rawId,
|
||
rawName,
|
||
nameFromIdParam,
|
||
routeNameHint,
|
||
fullOptions: opt
|
||
});
|
||
|
||
let idStr = normalizeFoodDetailIdString(rawId);
|
||
if (!idStr && routeNameHint) {
|
||
idStr = await this.resolveFoodIdByName(routeNameHint);
|
||
}
|
||
|
||
await this.loadFoodDetail(idStr, routeNameHint);
|
||
} catch (e) {
|
||
const msg = this.formatFoodDetailError(e);
|
||
console.error('[food-detail] onLoad 未捕获异常:', e, '→', msg);
|
||
this.loadError = `页面初始化异常(调试): ${msg}`;
|
||
this.showStaleHint = true;
|
||
this.applyFallbackDisplay(routeNameHint || '');
|
||
uni.showToast({
|
||
title: '已用本地参考数据展示,详见页内说明',
|
||
icon: 'none',
|
||
duration: 2200
|
||
});
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.food-detail-page {
|
||
min-height: 100vh;
|
||
background: #f4f5f7;
|
||
padding-bottom: 48rpx;
|
||
}
|
||
|
||
.stale-hint {
|
||
margin: 24rpx 32rpx 0;
|
||
padding: 20rpx 24rpx;
|
||
background: #fff8e6;
|
||
border: 1rpx solid #ffe0a3;
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.stale-hint-text {
|
||
font-size: 26rpx;
|
||
color: #8a6d3b;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.hero {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 420rpx;
|
||
background: #e8ecf2;
|
||
}
|
||
|
||
.hero-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.hero-image-placeholder {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.food-name-overlay {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 32rpx;
|
||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.65));
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.food-name-text {
|
||
font-size: 40rpx;
|
||
font-weight: 700;
|
||
color: #ffffff;
|
||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.safety-pill {
|
||
padding: 6rpx 20rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 24rpx;
|
||
}
|
||
.safety-pill.safe {
|
||
background: rgba(46, 204, 113, 0.95);
|
||
color: #fff;
|
||
}
|
||
.safety-pill.limited {
|
||
background: rgba(241, 196, 15, 0.95);
|
||
color: #333;
|
||
}
|
||
.safety-pill.careful {
|
||
background: rgba(231, 76, 60, 0.95);
|
||
color: #fff;
|
||
}
|
||
|
||
.content {
|
||
padding: 32rpx;
|
||
}
|
||
|
||
.category-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
margin-bottom: 24rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
.category-label {
|
||
color: #9fa5c0;
|
||
}
|
||
.category-value {
|
||
color: #3e5481;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.nutrient-card {
|
||
background: #ffffff;
|
||
border-radius: 16rpx;
|
||
padding: 28rpx 32rpx;
|
||
margin-bottom: 24rpx;
|
||
border: 1rpx solid #e5eaf2;
|
||
}
|
||
|
||
.nutrient-card-title {
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: #3e5481;
|
||
display: block;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.nutrition-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16rpx 0;
|
||
border-bottom: 1rpx solid #f0f2f5;
|
||
}
|
||
.nutrition-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.nutrition-row-label {
|
||
font-size: 28rpx;
|
||
color: #6b7a99;
|
||
}
|
||
.nutrition-row-value {
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
}
|
||
.nutrition-row-value.green {
|
||
color: #27ae60;
|
||
}
|
||
.nutrition-row-value.orange {
|
||
color: #e67e22;
|
||
}
|
||
.nutrition-row-value.red {
|
||
color: #e74c3c;
|
||
}
|
||
|
||
.desc-block {
|
||
background: #ffffff;
|
||
border-radius: 16rpx;
|
||
padding: 28rpx 32rpx;
|
||
margin-bottom: 24rpx;
|
||
border: 1rpx solid #e5eaf2;
|
||
}
|
||
.desc-block.caution {
|
||
border-color: #f5c6cb;
|
||
background: #fff5f5;
|
||
}
|
||
.desc-title {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #3e5481;
|
||
display: block;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
.desc-body {
|
||
font-size: 26rpx;
|
||
color: #5c6b8a;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.debug-error {
|
||
margin-top: 16rpx;
|
||
padding: 20rpx;
|
||
background: #f8f9fa;
|
||
border-radius: 8rpx;
|
||
border: 1rpx dashed #ccc;
|
||
}
|
||
.debug-error-label {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
.debug-error-text {
|
||
font-size: 22rpx;
|
||
color: #666;
|
||
word-break: break-all;
|
||
}
|
||
</style>
|